feat: 画图台并发重构 + 品牌定制 + 用户备注 + provider/UI 多项修复
画图台(并发出图):
- 不再锁定 UI:点「生成」开独立任务,可连续多次并发
- 结果网格一行5个、最多10张,进行中/成功/失败状态回显,刷新保留进行中
- 生图张数 1/2/3/4,各自独立计费出卡
- 点图=参考图(单张替换/多张替换末位);首尾帧模型点视频=抓末帧设为首帧,否则放大
- /logs 新增 statuses=pending,success 服务端过滤(status IN 专用 SQL)
品牌定制(设置→网站):
- 自定义 Logo 图片 + 子标题(公开页头部 + 管理侧栏)
- 邮件验证码标题改用站点名:{title} 邮箱验证码
提示词复制:
- 去掉复制按钮,点提示词文字即复制(预览/后台日志/图片管理/画图记录),统一弹「指令已复制」
- 新增 utils/clipboard.js:execCommand 回退,非安全上下文(http/IP)也能复制
用户管理:列表加「备注」列,新建/编辑可填改备注(默认空)
provider 修复:
- grok 401 正确判死封号(markTokenFailure 漏了 grok 池)
- grok 视频支持 15s
- custom 上游报错去敏感(抹掉上游 URL/IP,改英文短描述)
- custom 去掉额度耗尽锁定:429/欠费当临时错误,账号保持 active
UI/其它:
- 展示位弹窗浅色主题适配(tab 选中高亮、输入框边框)— 主题变量 + 中心补丁
- 自定义模型:时长可填任意秒数 + 15s 预设
- 首页设置/卡密弹窗去固定高度与滚动条
- 顶部菜单「记录」→「图片」
- 下线 Flow provider(代码移除)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
|||||||
func seedDefaults(ctx context.Context, db *gorm.DB) error {
|
func seedDefaults(ctx context.Context, db *gorm.DB) error {
|
||||||
defaults := []model.SiteSetting{
|
defaults := []model.SiteSetting{
|
||||||
{Key: "site.title", Value: "Vivid"},
|
{Key: "site.title", Value: "Vivid"},
|
||||||
|
{Key: "site.logo", Value: ""},
|
||||||
|
{Key: "site.subtitle", Value: ""},
|
||||||
{Key: "contact.qq", Value: "1114639355"},
|
{Key: "contact.qq", Value: "1114639355"},
|
||||||
{Key: "contact.qq_link", Value: "https://qm.qq.com/q/ItgCcNA7ac"},
|
{Key: "contact.qq_link", Value: "https://qm.qq.com/q/ItgCcNA7ac"},
|
||||||
{Key: "contact.qq_group", Value: "1106849765"},
|
{Key: "contact.qq_group", Value: "1106849765"},
|
||||||
@@ -47,5 +49,35 @@ func seedDefaults(ctx context.Context, db *gorm.DB) error {
|
|||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,16 +26,11 @@ func (h *AdminReadHandler) Users(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := make([]gin.H, 0, len(users))
|
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 {
|
for _, user := range users {
|
||||||
row := userPublic(user)
|
row := userPublic(user)
|
||||||
row["generation_count"] = generationCounts[user.ID]
|
row["generation_count"] = user.GenerationCount
|
||||||
out = append(out, row)
|
out = append(out, row)
|
||||||
}
|
}
|
||||||
delete(stats, "generation_counts")
|
|
||||||
c.JSON(http.StatusOK, gin.H{"data": out, "stats": stats})
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -21,5 +21,11 @@ func (h *SiteHandler) Public(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site"})
|
||||||
return
|
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),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,26 @@ func NewSiteSettingsHandler(site *service.SiteService) *SiteSettingsHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *SiteSettingsHandler) Get(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site settings"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site settings"})
|
||||||
return
|
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) {
|
func (h *SiteSettingsHandler) Put(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
var body struct {
|
var body struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
Logo string `json:"logo"`
|
||||||
|
Subtitle string `json:"subtitle"`
|
||||||
Contact service.Contact `json:"contact"`
|
Contact service.Contact `json:"contact"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
@@ -39,14 +48,21 @@ func (h *SiteSettingsHandler) Put(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "网页主标题不能为空"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "网页主标题不能为空"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updated, err := h.site.SetTitle(c.Request.Context(), title)
|
updated, err := h.site.SetTitle(ctx, title)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save site settings"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save site settings"})
|
||||||
return
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save contact info"})
|
||||||
return
|
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),
|
||||||
|
}})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"backend/internal/service"
|
"backend/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -176,6 +177,16 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) {
|
|||||||
offset := parseInt(c.Query("offset"), 0)
|
offset := parseInt(c.Query("offset"), 0)
|
||||||
kind := c.Query("kind")
|
kind := c.Query("kind")
|
||||||
status := c.Query("status")
|
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
|
// Secure-by-default: always scope to the caller's OWN records. This endpoint
|
||||||
// serves the front-end 日志 / 创作记录 pages, so an admin viewing their personal
|
// serves the front-end 日志 / 创作记录 pages, so an admin viewing their personal
|
||||||
// records must NOT see other users' work. Only an admin who explicitly opts
|
// 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.
|
// rows with real media (success + stored file), not failed/pending events.
|
||||||
hasFile := c.Query("has_file") == "1" || c.Query("has_file") == "true"
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||||
return
|
return
|
||||||
@@ -430,7 +441,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
|||||||
"type": "video",
|
"type": "video",
|
||||||
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
||||||
"resolutions": []string{"720p"},
|
"resolutions": []string{"720p"},
|
||||||
"durations": []string{"6s", "10s"},
|
"durations": []string{"6s", "10s", "15s"},
|
||||||
"max_reference_images": 6,
|
"max_reference_images": 6,
|
||||||
"reference_mode": "asset",
|
"reference_mode": "asset",
|
||||||
"description": "Grok Imagine video (文/图生视频)",
|
"description": "Grok Imagine video (文/图生视频)",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type User struct {
|
|||||||
InviteRewardAt *time.Time
|
InviteRewardAt *time.Time
|
||||||
CheckinLast string `gorm:"size:32"`
|
CheckinLast string `gorm:"size:32"`
|
||||||
CheckinStreak int `gorm:"not null;default:0"`
|
CheckinStreak int `gorm:"not null;default:0"`
|
||||||
|
GenerationCount int64 `gorm:"not null;default:0"`
|
||||||
LastLoginAt *time.Time
|
LastLoginAt *time.Time
|
||||||
LastLoginIP string `gorm:"size:128"`
|
LastLoginIP string `gorm:"size:128"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -111,6 +112,10 @@ type ModelConfig struct {
|
|||||||
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
|
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
|
||||||
// back to created_at desc. Default 0.
|
// back to created_at desc. Default 0.
|
||||||
Weight int `gorm:"not null;default:0;index"`
|
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
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -197,5 +202,15 @@ func AutoMigrateModels() []any {
|
|||||||
&TokenAccount{},
|
&TokenAccount{},
|
||||||
&RefreshProfile{},
|
&RefreshProfile{},
|
||||||
&SiteSetting{},
|
&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
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,32 @@ type Client struct{}
|
|||||||
|
|
||||||
func NewClient() *Client { return &Client{} }
|
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} }
|
func httpClient() *http.Client { return &http.Client{Timeout: 10 * time.Minute} }
|
||||||
|
|
||||||
// GenerateImage calls the upstream OpenAI image API. With reference images it
|
// 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)
|
resp, err := httpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
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":
|
case "failed", "error", "canceled", "cancelled":
|
||||||
reason := stringValue(job["error"])
|
reason := stringValue(job["error"])
|
||||||
if isCreditError(reason) {
|
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))
|
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)
|
resp, err := httpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
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)
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
resp, err := httpClient().Do(req)
|
resp, err := httpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
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)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, d.URL, nil)
|
||||||
resp, err := httpClient().Do(req)
|
resp, err := httpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return io.ReadAll(resp.Body)
|
return io.ReadAll(resp.Body)
|
||||||
@@ -249,12 +276,14 @@ func mapStatus(status int, body []byte) error {
|
|||||||
case status == 401 || status == 403:
|
case status == 401 || status == 403:
|
||||||
return fmt.Errorf("%w: %d %s", ErrAuth, status, clip(body, 160))
|
return fmt.Errorf("%w: %d %s", ErrAuth, status, clip(body, 160))
|
||||||
case status == 429:
|
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:
|
case status >= 500:
|
||||||
return fmt.Errorf("%w: %d %s", ErrTemporaryUpstream, status, clip(body, 160))
|
return fmt.Errorf("%w: %d %s", ErrTemporaryUpstream, status, clip(body, 160))
|
||||||
default:
|
default:
|
||||||
if isCreditError(string(body)) {
|
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))
|
return fmt.Errorf("custom: %d %s", status, clip(body, 160))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (c *Client) GenerateVideo(ctx context.Context, token, prompt, aspectRatio,
|
|||||||
if strings.TrimSpace(resolution) == "" {
|
if strings.TrimSpace(resolution) == "" {
|
||||||
resolution = "720p"
|
resolution = "720p"
|
||||||
}
|
}
|
||||||
if seconds != 6 && seconds != 10 {
|
if seconds != 6 && seconds != 10 && seconds != 15 {
|
||||||
seconds = 10
|
seconds = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ type EventListFilter struct {
|
|||||||
Limit int
|
Limit int
|
||||||
Offset int
|
Offset int
|
||||||
Kind string
|
Kind string
|
||||||
Status string
|
Status string // single status (status = ?)
|
||||||
|
Statuses []string // multiple statuses (status IN (?)) — used by the 画图台 grid
|
||||||
Since *time.Time
|
Since *time.Time
|
||||||
UserID string
|
UserID string
|
||||||
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
|
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 != "" {
|
if filter.Status != "" {
|
||||||
q = q.Where("status = ?", filter.Status)
|
q = q.Where("status = ?", filter.Status)
|
||||||
}
|
}
|
||||||
|
if len(filter.Statuses) > 0 {
|
||||||
|
q = q.Where("status IN ?", filter.Statuses)
|
||||||
|
}
|
||||||
if filter.Since != nil {
|
if filter.Since != nil {
|
||||||
q = q.Where("ts > ?", *filter.Since)
|
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 {
|
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
|
// 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
|
// MarkVideoReady completes an async video job: status=success, file=upstream URL
|
||||||
// (proxied on /content — never persisted), elapsed.
|
// (proxied on /content — never persisted), elapsed.
|
||||||
func (r *EventRepository) MarkVideoReady(ctx context.Context, eventID, fileURL string, elapsedMS int) error {
|
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{}).
|
Model(&model.EventLog{}).
|
||||||
Where("id = ?", eventID).
|
Where("id = ? AND status <> ?", eventID, "success").
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"file": fileURL,
|
"file": fileURL,
|
||||||
"error": "",
|
"error": "",
|
||||||
"elapsed_ms": elapsedMS,
|
"elapsed_ms": elapsedMS,
|
||||||
"updated_at": time.Now(),
|
"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 {
|
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.
|
// "成功 + abandoned" at once.
|
||||||
patch["error"] = ""
|
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{}).
|
Model(&model.EventLog{}).
|
||||||
Where("id = ?", eventID).
|
Where("id = ? AND status <> ?", eventID, status).
|
||||||
Updates(patch).Error
|
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:
|
// MarkRefunded atomically claims the right to refund this event exactly once:
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ func NewModelRepository(db *gorm.DB) *ModelRepository {
|
|||||||
return &ModelRepository{db: db}
|
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) {
|
func (r *ModelRepository) List(ctx context.Context) ([]model.ModelConfig, error) {
|
||||||
var items []model.ModelConfig
|
var items []model.ModelConfig
|
||||||
// Higher weight floats to the top of the dropdown / admin list; ties fall
|
// Higher weight floats to the top of the dropdown / admin list; ties fall
|
||||||
|
|||||||
@@ -87,6 +87,16 @@ func (r *UserRepository) GetByInviteCode(ctx context.Context, code string) (*mod
|
|||||||
return &user, nil
|
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) {
|
func (r *UserRepository) List(ctx context.Context) ([]model.User, error) {
|
||||||
var users []model.User
|
var users []model.User
|
||||||
if err := r.db.WithContext(ctx).Preload("APIKeys").Order("created_at desc").Find(&users).Error; err != nil {
|
if err := r.db.WithContext(ctx).Preload("APIKeys").Order("created_at desc").Find(&users).Error; err != nil {
|
||||||
|
|||||||
@@ -41,19 +41,12 @@ func (s *AdminReadService) Users(ctx context.Context) ([]model.User, map[string]
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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)
|
stats, err := s.users.Stats(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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
|
return users, stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +59,6 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
counts, err := s.events.ModelSuccessCounts(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]map[string]any, 0, len(items))
|
out := make([]map[string]any, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
out = append(out, map[string]any{
|
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,
|
"max_reference_images": item.MaxReferenceImages,
|
||||||
"reference_mode": item.ReferenceMode,
|
"reference_mode": item.ReferenceMode,
|
||||||
"weight": item.Weight,
|
"weight": item.Weight,
|
||||||
"generation_count": counts[item.ID],
|
"generation_count": item.GenerationCount,
|
||||||
"created_at": item.CreatedAt,
|
"created_at": item.CreatedAt,
|
||||||
"updated_at": item.UpdatedAt,
|
"updated_at": item.UpdatedAt,
|
||||||
})
|
})
|
||||||
@@ -97,12 +86,13 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
|||||||
return out, nil
|
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{
|
items, total, err := s.events.List(ctx, repo.EventListFilter{
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
Status: status,
|
Status: status,
|
||||||
|
Statuses: statuses,
|
||||||
Since: since,
|
Since: since,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ExcludeSource: excludeSource,
|
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) {
|
func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
dayCut := now.Add(-24 * time.Hour)
|
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)
|
day, err := s.events.WindowStats(ctx, dayCut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -199,6 +189,12 @@ func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// Per-window top-N analytics so the frontend can toggle 24h / 7d without a
|
||||||
// re-fetch (the lists are small — top 6 / top 5).
|
// 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,
|
"dau": dau,
|
||||||
"wau": wau,
|
"wau": wau,
|
||||||
"hourly": hourly,
|
"hourly": hourly,
|
||||||
|
"lifetime": lifetime,
|
||||||
"analytics": map[string]any{
|
"analytics": map[string]any{
|
||||||
"day": dayAnalytics,
|
"day": dayAnalytics,
|
||||||
"week": weekAnalytics,
|
"week": weekAnalytics,
|
||||||
|
|||||||
@@ -358,6 +358,12 @@ func (s *AppSettingsService) loadSMTPConfig(ctx context.Context) (SMTPConfig, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return SMTPConfig{}, err
|
return SMTPConfig{}, err
|
||||||
}
|
}
|
||||||
|
// The verification-email subject uses the site title, e.g. "<title> 邮箱验证码".
|
||||||
|
title, _ := s.settings.GetValue(ctx, "site.title")
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if title == "" {
|
||||||
|
title = "Vivid"
|
||||||
|
}
|
||||||
return SMTPConfig{
|
return SMTPConfig{
|
||||||
Host: current.Host,
|
Host: current.Host,
|
||||||
Port: current.Port,
|
Port: current.Port,
|
||||||
@@ -365,6 +371,7 @@ func (s *AppSettingsService) loadSMTPConfig(ctx context.Context) (SMTPConfig, er
|
|||||||
Password: password,
|
Password: password,
|
||||||
FromAddr: current.FromAddr,
|
FromAddr: current.FromAddr,
|
||||||
UseTLS: current.UseTLS,
|
UseTLS: current.UseTLS,
|
||||||
|
Subject: title + " 邮箱验证码",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ func (s *SiteService) SetTitle(ctx context.Context, title string) (string, error
|
|||||||
return title, nil
|
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.
|
// Contact is the admin-editable "联系我们" info shown in the public 关于 section.
|
||||||
type Contact struct {
|
type Contact struct {
|
||||||
QQ string `json:"qq"`
|
QQ string `json:"qq"`
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type SMTPConfig struct {
|
|||||||
Password string
|
Password string
|
||||||
FromAddr string
|
FromAddr string
|
||||||
UseTLS bool
|
UseTLS bool
|
||||||
|
Subject string // verification-code email subject (empty → default)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SMTPService struct{}
|
type SMTPService struct{}
|
||||||
@@ -35,7 +36,10 @@ func (s *SMTPService) SendCode(ctx context.Context, cfg SMTPConfig, to, code, pu
|
|||||||
if purpose == "reset" {
|
if purpose == "reset" {
|
||||||
action = "找回密码"
|
action = "找回密码"
|
||||||
}
|
}
|
||||||
subject := "Vivid AI 邮箱验证码"
|
subject := strings.TrimSpace(cfg.Subject)
|
||||||
|
if subject == "" {
|
||||||
|
subject = "Vivid AI 邮箱验证码"
|
||||||
|
}
|
||||||
body := fmt.Sprintf("你正在进行%s,验证码为:%s\n\n验证码 6 分钟内有效。", action, code)
|
body := fmt.Sprintf("你正在进行%s,验证码为:%s\n\n验证码 6 分钟内有效。", action, code)
|
||||||
msg := buildSMTPMessage(cfg.FromAddr, to, subject, body)
|
msg := buildSMTPMessage(cfg.FromAddr, to, subject, body)
|
||||||
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
||||||
|
|||||||
@@ -906,6 +906,7 @@ func (s *TokenService) ImportGrokToken(ctx context.Context, ssoToken, tokenID st
|
|||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (s *TokenService) checkPendingGrok(tokenID, ssoToken string) {
|
func (s *TokenService) checkPendingGrok(tokenID, ssoToken string) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
|||||||
@@ -495,6 +495,10 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil {
|
if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = s.models.IncrementGenerationCount(ctx, modelItem.ID)
|
||||||
|
if principal != nil && principal.User != nil {
|
||||||
|
_ = s.users.IncrementGenerationCount(ctx, principal.User.ID)
|
||||||
|
}
|
||||||
if charge {
|
if charge {
|
||||||
_ = s.maybeGrantInviteReward(ctx, principal)
|
_ = 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 {
|
if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = s.models.IncrementGenerationCount(ctx, modelItem.ID)
|
||||||
|
if principal != nil && principal.User != nil {
|
||||||
|
_ = s.users.IncrementGenerationCount(ctx, principal.User.ID)
|
||||||
|
}
|
||||||
if charge {
|
if charge {
|
||||||
_ = s.maybeGrantInviteReward(ctx, principal)
|
_ = 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 {
|
if err := s.events.MarkVideoReady(ctx, eventID, videoURL, int(time.Since(startedAt).Milliseconds())); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
_ = s.models.IncrementGenerationCount(ctx, modelItem.ID)
|
||||||
|
if principal != nil && principal.User != nil {
|
||||||
|
_ = s.users.IncrementGenerationCount(ctx, principal.User.ID)
|
||||||
|
}
|
||||||
_ = s.maybeGrantInviteReward(ctx, principal)
|
_ = 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
|
// the cookie. chatgpt/runway/leonardo auth means the stored credential is
|
||||||
// dead — a raw JWT (chatgpt/runway) or a cookie whose session no longer
|
// dead — a raw JWT (chatgpt/runway) or a cookie whose session no longer
|
||||||
// authenticates (leonardo) — there's nothing left to refresh from.
|
// 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["status"] = "disabled"
|
||||||
patch["dead"] = true
|
patch["dead"] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 IMG_RES = ['1K', '2K', '4K']
|
||||||
const VID_RES = ['720p', '1080p', '2K', '4K']
|
const VID_RES = ['720p', '1080p', '2K', '4K']
|
||||||
const ALL_RES = ['1K', '2K', '4K', '720p', '1080p']
|
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 id = ref('')
|
||||||
const type = ref('image')
|
const type = ref('image')
|
||||||
@@ -21,9 +21,30 @@ const weight = ref(0)
|
|||||||
// tier -> { price, agent } — blank price means the tier is NOT supported.
|
// tier -> { price, agent } — blank price means the tier is NOT supported.
|
||||||
const res = ref(Object.fromEntries(ALL_RES.map((r) => [r, { price: '', agent: '' }])))
|
const res = ref(Object.fromEntries(ALL_RES.map((r) => [r, { price: '', agent: '' }])))
|
||||||
const dur = ref(Object.fromEntries(DUR_OPTS.map((d) => [d, { 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 error = ref('')
|
||||||
const saving = ref(false)
|
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')
|
const isVideo = computed(() => type.value === 'video')
|
||||||
// Resolution tiers depend on type: image = 1K/2K/4K, video = 540p/720p/1080p/2K/4K.
|
// Resolution tiers depend on type: image = 1K/2K/4K, video = 540p/720p/1080p/2K/4K.
|
||||||
const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES))
|
const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES))
|
||||||
@@ -145,12 +166,22 @@ async function save() {
|
|||||||
<div v-if="isVideo">
|
<div v-if="isVideo">
|
||||||
<label class="text-xs text-slate-500 block mb-1.5">时长 · 价格(总价 = 分辨率价 + 时长价;<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
<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 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>
|
<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].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="代理价(留空跟随)" />
|
<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>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
// admin (图片管理 / 日志) and the user-facing (画图记录) surfaces. Parent controls
|
// admin (图片管理 / 日志) and the user-facing (画图记录) surfaces. Parent controls
|
||||||
// mount via v-if and passes the resolved media URL + meta; the component owns the
|
// 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.
|
// 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'
|
import Icon from './Icon.vue'
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
src: { type: String, required: true }, // resolved media URL (generatedUrl)
|
src: { type: String, required: true }, // resolved media URL (generatedUrl)
|
||||||
kind: { type: String, default: 'image' }, // 'image' | 'video'
|
kind: { type: String, default: 'image' }, // 'image' | 'video'
|
||||||
prompt: { type: String, default: '' },
|
prompt: { type: String, default: '' },
|
||||||
@@ -14,12 +16,25 @@ defineProps({
|
|||||||
downloadName: { type: String, default: '' },
|
downloadName: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['close'])
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<transition name="lb-fade" appear>
|
<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"
|
<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')">
|
@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
|
<!-- 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). -->
|
lines up flush with the image's left & right edges (one clean column). -->
|
||||||
<div class="flex flex-col max-h-full max-w-full">
|
<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="mt-3 flex items-start justify-between gap-4 text-white">
|
||||||
<div class="min-w-0 flex-1">
|
<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="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 v-if="metaSub" class="text-xs text-white/45 mt-1">{{ metaSub }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ const currentLabel = computed(() => route.meta?.label || '')
|
|||||||
<!-- ===== Sidebar ===== -->
|
<!-- ===== Sidebar ===== -->
|
||||||
<aside class="w-60 shrink-0 border-r border-[color:var(--hairline)] bg-[var(--surface)] backdrop-blur-md flex flex-col">
|
<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">
|
<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="leading-tight min-w-0">
|
||||||
<div class="text-sm font-semibold truncate tracking-tight text-[color:var(--fg)]">{{ site.title }}</div>
|
<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>
|
<div class="text-[11px] text-[color:var(--fg-3)] truncate">Admin</div>
|
||||||
|
|||||||
@@ -107,10 +107,11 @@ const currentLabel = computed(() => {
|
|||||||
stamp doesn't jump around. -->
|
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">
|
<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">
|
<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 }}
|
{{ site.title }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<router-link v-if="showBalance" to="/settings"
|
<router-link v-if="showBalance" to="/settings"
|
||||||
class="text-xs text-[color:var(--fg-2)] hover:text-[color:var(--fg)] tabular-nums transition-colors">
|
class="text-xs text-[color:var(--fg-2)] hover:text-[color:var(--fg)] tabular-nums transition-colors">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
{ path: '', component: HomeView, meta: { label: '首页' } },
|
{ path: '', component: HomeView, meta: { label: '首页' } },
|
||||||
{ path: 'user', component: PlaygroundView, 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: 'mylogs', component: UserLogsTableView, meta: { label: '日志' } },
|
||||||
{ path: 'invite', component: InviteView, meta: { label: '邀请' } },
|
{ path: 'invite', component: InviteView, meta: { label: '邀请' } },
|
||||||
{ path: 'docs', component: DocsView, meta: { label: '文档' } },
|
{ path: 'docs', component: DocsView, meta: { label: '文档' } },
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const BASE = import.meta.env.VITE_API_BASE || ''
|
|||||||
|
|
||||||
export const site = reactive({
|
export const site = reactive({
|
||||||
title: 'Vivid',
|
title: 'Vivid',
|
||||||
|
logo: '',
|
||||||
|
subtitle: '',
|
||||||
// Defaults so the 关于 page is never blank even if /site hasn't loaded (or a
|
// 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
|
// cache serves an older payload without `contact`). The backend value, once
|
||||||
// fetched, overrides these.
|
// fetched, overrides these.
|
||||||
@@ -28,6 +30,8 @@ export async function loadSite() {
|
|||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const data = await r.json()
|
const data = await r.json()
|
||||||
if (data.title) site.title = String(data.title)
|
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 }
|
if (data.contact) site.contact = { ...site.contact, ...data.contact }
|
||||||
}
|
}
|
||||||
} catch { /* offline — keep the default. */ }
|
} catch { /* offline — keep the default. */ }
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset
|
|||||||
color: var(--fg); background: var(--hover);
|
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; }
|
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
|
/* Selected COLORED filter pills — their scoped (dark) selected colors get
|
||||||
overridden by the neutral `.fp` rescue above, so re-state a light-mode
|
overridden by the neutral `.fp` rescue above, so re-state a light-mode
|
||||||
variant (tinted bg + dark-enough text + colored ring) with equal specificity
|
variant (tinted bg + dark-enough text + colored ring) with equal specificity
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,7 +182,7 @@ const pageNumbers = computed(() => {
|
|||||||
<span class="text-xs font-medium text-white/75">刚生成 {{ lastBatch.length }} 个 — 请复制保存</span>
|
<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>
|
<button @click="copyBatch" class="text-xs btn-soft"><Icon name="copy" class="w-3.5 h-3.5" /> 全部复制</button>
|
||||||
</div>
|
</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 v-for="code in lastBatch" :key="code">{{ code }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,12 +37,14 @@ async function saveMedia() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- site (branding shown across the app) ----
|
// ---- 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)
|
const siteBusy = ref(false); const siteSaved = ref(false)
|
||||||
async function loadSite() {
|
async function loadSite() {
|
||||||
const r = await api('/settings/site')
|
const r = await api('/settings/site')
|
||||||
if (r.ok && r.data) {
|
if (r.ok && r.data) {
|
||||||
siteForm.title = r.data.title || ''
|
siteForm.title = r.data.title || ''
|
||||||
|
siteForm.logo = r.data.logo || ''
|
||||||
|
siteForm.subtitle = r.data.subtitle || ''
|
||||||
const c = r.data.contact || {}
|
const c = r.data.contact || {}
|
||||||
siteForm.qq = c.qq || ''; siteForm.qq_link = c.qq_link || ''
|
siteForm.qq = c.qq || ''; siteForm.qq_link = c.qq_link || ''
|
||||||
siteForm.qq_group = c.qq_group || ''
|
siteForm.qq_group = c.qq_group || ''
|
||||||
@@ -54,10 +56,14 @@ async function saveSite() {
|
|||||||
siteBusy.value = true; siteSaved.value = false
|
siteBusy.value = true; siteSaved.value = false
|
||||||
const r = await api('/settings/site', jsonBody('PUT', {
|
const r = await api('/settings/site', jsonBody('PUT', {
|
||||||
title: siteForm.title,
|
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 },
|
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
|
siteBusy.value = false
|
||||||
if (r.ok && r.data) {
|
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 /
|
// Mirror the change into the shared `site` store so every header /
|
||||||
// wordmark / tab title updates without a reload. The PUT response is
|
// wordmark / tab title updates without a reload. The PUT response is
|
||||||
// nested ({ ok, data: { title } }) unlike the flat GET, so read the
|
// 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>
|
<span><span class="lbl">网页主标题</span><span class="hint">显示在浏览器标签、首页 Logo、侧栏和登录卡上。未设置时默认显示 "Vivid"。</span></span>
|
||||||
<input v-model="siteForm.title" placeholder="Vivid" class="txt" />
|
<input v-model="siteForm.title" placeholder="Vivid" class="txt" />
|
||||||
</label>
|
</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">
|
<label class="row">
|
||||||
<span><span class="lbl">联系 QQ</span><span class="hint">QQ 号(显示用)。留空则不显示该项。</span></span>
|
<span><span class="lbl">联系 QQ</span><span class="hint">QQ 号(显示用)。留空则不显示该项。</span></span>
|
||||||
<input v-model="siteForm.qq" placeholder="1114639355" class="txt" />
|
<input v-model="siteForm.qq" placeholder="1114639355" class="txt" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { api, generatedUrl } from '../api'
|
import { api, generatedUrl } from '../api'
|
||||||
import { fmtTs, fmtSize } from '../utils/format'
|
import { fmtTs, fmtSize } from '../utils/format'
|
||||||
|
import { copyText } from '../utils/clipboard'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||||
|
|
||||||
@@ -40,12 +41,12 @@ function absUrl(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyLink(name) {
|
async function copyLink(name) {
|
||||||
try {
|
flash(await copyText(absUrl(name)) ? '链接已复制' : '复制失败')
|
||||||
await navigator.clipboard.writeText(absUrl(name))
|
|
||||||
flash('链接已复制')
|
|
||||||
} catch {
|
|
||||||
flash('复制失败')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyPrompt(f) {
|
||||||
|
if (!f.prompt) return
|
||||||
|
flash(await copyText(f.prompt) ? '指令已复制' : '复制失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
let toastTimer = null
|
let toastTimer = null
|
||||||
@@ -173,8 +174,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
|||||||
|
|
||||||
<!-- caption: prompt (truncated 2 lines) + meta line -->
|
<!-- caption: prompt (truncated 2 lines) + meta line -->
|
||||||
<div class="absolute inset-x-0 bottom-0 p-3 pointer-events-none">
|
<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"
|
<div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1 transition-colors"
|
||||||
:title="f.prompt || f.name">
|
: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() }}
|
{{ f.prompt || f.name.split('/').pop() }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums">
|
<div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
|
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
|
||||||
|
import { copyText } from '../utils/clipboard'
|
||||||
import { generatedUrl } from '../api'
|
import { generatedUrl } from '../api'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||||
@@ -16,6 +17,14 @@ const search = ref('')
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(15)
|
const pageSize = ref(15)
|
||||||
const total = ref(0)
|
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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -281,7 +290,10 @@ const sourcePill = (s) => ({
|
|||||||
<!-- Prompt with error inline; the error reads as a follow-up rather
|
<!-- Prompt with error inline; the error reads as a follow-up rather
|
||||||
than wasting a whole column when there's nothing to show. -->
|
than wasting a whole column when there's nothing to show. -->
|
||||||
<td class="px-3 py-3.5 align-middle min-w-0">
|
<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">
|
<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" />
|
<Icon name="close" class="w-3 h-3 shrink-0" />
|
||||||
{{ e.error }}
|
{{ 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(' · ')"
|
:meta="[previewing.model, previewing.ratio, previewing.resolution, previewing.duration, fmtMs(previewing.elapsed_ms)].filter(Boolean).join(' · ')"
|
||||||
:download-name="previewing.file"
|
:download-name="previewing.file"
|
||||||
@close="closePreview" />
|
@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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 day = computed(() => dash.value?.day || EMPTY_WINDOW)
|
||||||
const week = computed(() => dash.value?.week || 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))
|
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
|
// 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
|
// ---- range-toggled top-N analytics (both windows ship in the payload, so the
|
||||||
// 24h/7d switch is instant — no re-fetch) ----
|
// 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 analytics = computed(() => dash.value?.analytics?.[range.value] || { models: [], failures: [], top_users: [] })
|
||||||
const modelUsage = computed(() => analytics.value.models || [])
|
const modelUsage = computed(() => analytics.value.models || [])
|
||||||
const usageMax = computed(() => Math.max(1, ...modelUsage.value.map((m) => m.count)))
|
const usageMax = computed(() => Math.max(1, ...modelUsage.value.map((m) => m.count)))
|
||||||
@@ -153,7 +155,7 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===== KPI strip ===== -->
|
<!-- ===== 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="card p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -196,6 +198,24 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
</div>
|
</div>
|
||||||
</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="card p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -247,7 +267,7 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="text-xs text-white/55">活跃用户 · 24h</div>
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -342,7 +362,7 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<h2 class="text-sm font-semibold">24 小时生成趋势</h2>
|
||||||
<div class="text-[11px] text-white/45 flex items-center gap-3">
|
<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>
|
<span class="tabular-nums">峰值 {{ hourMax }}/h</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5 flex-1 flex flex-col">
|
||||||
<div class="flex items-end gap-[3px] h-32">
|
<div class="flex items-end gap-[3px] flex-1 min-h-[8rem]">
|
||||||
<div v-for="(b, i) in hourBuckets" :key="i"
|
<div v-for="(b, i) in hourBuckets" :key="i"
|
||||||
class="group/bar relative flex-1 flex flex-col justify-end rounded-t overflow-visible"
|
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) + '%' }">
|
:style="{ height: Math.max(4, ((b.image + b.video) / hourMax) * 100) + '%' }">
|
||||||
@@ -385,7 +405,7 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
<button @click="range = 'week'"
|
<button @click="range = 'week'"
|
||||||
class="px-3 py-1 rounded-md transition-colors"
|
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'">
|
:class="range === 'week' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'">
|
||||||
近 7d
|
近 3d
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -472,7 +492,7 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
<div class="text-[11px] text-white/40 mt-1">{{ day.success }} 次成功生成</div>
|
<div class="text-[11px] text-white/40 mt-1">{{ day.success }} 次成功生成</div>
|
||||||
</div>
|
</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-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 class="text-[11px] text-white/40 mt-1">{{ week.success }} 次成功生成</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { api, jsonBody } from '../api'
|
import { api, jsonBody, generatedUrl } from '../api'
|
||||||
import { auth, refreshMe } from '../auth'
|
import { auth, refreshMe } from '../auth'
|
||||||
import { draft, applyJobToDraft } from '../playground'
|
import { draft } from '../playground'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import SelectMenu from '../components/SelectMenu.vue'
|
import SelectMenu from '../components/SelectMenu.vue'
|
||||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||||
import { points, pointsLabel } from '../credits'
|
import { pointsLabel } from '../credits'
|
||||||
import { sortResolutions } from '../utils/format'
|
import { sortResolutions } from '../utils/format'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -38,29 +38,28 @@ watch(duration, (v) => { draft.duration = v })
|
|||||||
const refImages = ref([]) // [{ name, dataUrl }]
|
const refImages = ref([]) // [{ name, dataUrl }]
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
|
|
||||||
const busy = ref(false)
|
// Concurrent generation: each 生成 click fires an INDEPENDENT /generate and adds
|
||||||
// submitting = run() owns the busy/current state end-to-end while a /generate
|
// a card — the UI never locks, so several can run at once. `tasks` holds the
|
||||||
// call is in flight. The 2s poll() must NOT touch busy or current during this
|
// in-session cards (newest first); `history` fills the grid up to 10 with the
|
||||||
// window, or it races run() and the controls flicker unlocked mid-generation.
|
// user's recent finished results from the server.
|
||||||
const submitting = ref(false)
|
const tasks = ref([])
|
||||||
// Gateway-timeout statuses our BACKEND never emits — they mean a CDN/proxy
|
const history = ref([])
|
||||||
// (e.g. EdgeOne 524) gave up waiting while the synchronous /generate is STILL
|
// Gateway-timeout statuses our BACKEND never emits — a CDN/proxy (e.g. EdgeOne
|
||||||
// rendering server-side. Treat these as "still running", NOT a failure: keep the
|
// 524) gave up waiting while the synchronous /generate is STILL rendering. The
|
||||||
// controls locked and let poll() follow the live job to completion (出图).
|
// 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 GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
|
||||||
const error = ref('')
|
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 lightbox = ref(null)
|
||||||
const toast = ref('')
|
const toast = ref('')
|
||||||
let pollTimer = null
|
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 ----
|
// ---- derived ----
|
||||||
const models = computed(() =>
|
const models = computed(() =>
|
||||||
allModels.value.filter((m) => m.enabled !== false && m.type === mode.value),
|
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.
|
// of charging + failing upstream after the upload.
|
||||||
const MAX_REF_BYTES = 8 * 1024 * 1024
|
const MAX_REF_BYTES = 8 * 1024 * 1024
|
||||||
function onFiles(ev) {
|
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 room = Math.max(0, maxRefs.value - refImages.value.length)
|
||||||
const tooBig = []
|
const tooBig = []
|
||||||
let added = 0
|
let added = 0
|
||||||
@@ -198,7 +203,23 @@ function onFiles(ev) {
|
|||||||
error.value = tooBig.length
|
error.value = tooBig.length
|
||||||
? `图片超过 8MB 已跳过:${tooBig.join('、')}(请压缩后再传)`
|
? `图片超过 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) }
|
function removeRef(i) { refImages.value.splice(i, 1) }
|
||||||
|
|
||||||
@@ -241,17 +262,11 @@ function flash(msg) {
|
|||||||
toastTimer = setTimeout(() => (toast.value = ''), 1800)
|
toastTimer = setTimeout(() => (toast.value = ''), 1800)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyLink(url) {
|
// ---- generate (concurrent — no lock) ----
|
||||||
try {
|
// 生图 can request 1–4 images at once: each is an independent task/charge.
|
||||||
const abs = url.startsWith('http') ? url : location.origin + url
|
const count = ref(1)
|
||||||
await navigator.clipboard.writeText(abs)
|
const batchCount = computed(() => (mode.value === 'image' ? Math.max(1, Math.min(4, count.value)) : 1))
|
||||||
flash('链接已复制')
|
|
||||||
} catch {
|
|
||||||
flash('复制失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- generate ----
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!modelId.value) { error.value = '请选择模型'; return }
|
if (!modelId.value) { error.value = '请选择模型'; return }
|
||||||
if (!prompt.value.trim()) { error.value = '请输入提示词'; return }
|
if (!prompt.value.trim()) { error.value = '请输入提示词'; return }
|
||||||
@@ -263,11 +278,22 @@ async function run() {
|
|||||||
error.value = '该参数组合未定价 (留空 = 不支持)'
|
error.value = '该参数组合未定价 (留空 = 不支持)'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!canAfford.value) {
|
const n = batchCount.value
|
||||||
error.value = `积分不足 — 需要 ${pointsLabel(price.value)},余额 ${pointsLabel(credits.value)}`
|
if (price.value != null && credits.value < price.value * n) {
|
||||||
|
error.value = `积分不足 — 需要 ${pointsLabel(price.value * n)},余额 ${pointsLabel(credits.value)}`
|
||||||
return
|
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),
|
id: Math.random().toString(36).slice(2, 10),
|
||||||
model: modelId.value,
|
model: modelId.value,
|
||||||
kind: mode.value,
|
kind: mode.value,
|
||||||
@@ -275,7 +301,6 @@ async function run() {
|
|||||||
ratio: ratio.value,
|
ratio: ratio.value,
|
||||||
resolution: resolution.value,
|
resolution: resolution.value,
|
||||||
duration: mode.value === 'video' ? duration.value : '',
|
duration: mode.value === 'video' ? duration.value : '',
|
||||||
refs: refImages.value.length,
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
url: '',
|
url: '',
|
||||||
error: '',
|
error: '',
|
||||||
@@ -283,118 +308,144 @@ async function run() {
|
|||||||
charged: price.value,
|
charged: price.value,
|
||||||
ts: Date.now(),
|
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
|
// Optimistically deduct the price (server debits before generating; a failure
|
||||||
submitting.value = true
|
// refunds + refreshMe reconciles).
|
||||||
error.value = ''
|
if (auth.user && chargedPrice != null) {
|
||||||
statusText.value = mode.value === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
auth.user.credits = Math.max(0, Number(auth.user.credits || 0) - chargedPrice)
|
||||||
|
|
||||||
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 = {
|
const payload = {
|
||||||
model: modelId.value,
|
model: task.model, prompt: task.prompt, ratio: task.ratio, resolution: task.resolution,
|
||||||
prompt: prompt.value,
|
|
||||||
ratio: ratio.value,
|
|
||||||
resolution: resolution.value,
|
|
||||||
}
|
}
|
||||||
if (mode.value === 'video') payload.duration = duration.value
|
if (task.kind === 'video') payload.duration = task.duration
|
||||||
if (refImages.value.length) {
|
if (refsSnapshot.length) {
|
||||||
// Backend accepts raw base64 only — convert each ref (uploaded dataUrl or
|
const refs = await Promise.all(refsSnapshot.map(refToBase64))
|
||||||
// restored /images URL) to base64 at submit time.
|
|
||||||
const refs = await Promise.all(refImages.value.map(refToBase64))
|
|
||||||
payload.reference_images = refs.filter(Boolean)
|
payload.reference_images = refs.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single charged call: the server debits the price atomically BEFORE
|
try {
|
||||||
// generating and refunds on failure, so the client can't skip the charge.
|
|
||||||
const r = await api('/generate', jsonBody('POST', payload))
|
const r = await api('/generate', jsonBody('POST', payload))
|
||||||
|
|
||||||
if (r.ok && r.data?.url) {
|
if (r.ok && r.data?.url) {
|
||||||
job.status = 'done'
|
task.status = 'done'
|
||||||
job.url = r.data.url
|
task.url = r.data.url
|
||||||
job.elapsed_ms = r.data.elapsed_ms
|
task.elapsed_ms = r.data.elapsed_ms
|
||||||
job.charged = r.data.charged ?? price.value
|
task.charged = r.data.charged ?? chargedPrice
|
||||||
if (auth.user && r.data.credits != null) auth.user.credits = r.data.credits
|
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)) {
|
} else if (GATEWAY_TIMEOUT.has(r.status)) {
|
||||||
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。不当失败、不解锁:
|
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。保持 running,
|
||||||
// 保持 busy=true,交给下面的 poll() + 2s 轮询跟到出图("不出图就不闪")。
|
// loadHistory() 在结果落库后认领它。
|
||||||
statusText.value = mode.value === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
task.status = 'running'
|
||||||
} else {
|
} else {
|
||||||
// 真失败:服务端已退款,resync 余额并解锁。
|
|
||||||
await refreshMe()
|
await refreshMe()
|
||||||
job.status = 'failed'
|
task.status = 'failed'
|
||||||
job.error = r.data?.detail || `失败 (${r.status})`
|
task.error = r.data?.detail || `失败 (${r.status})`
|
||||||
statusText.value = ''
|
|
||||||
busy.value = false // 真失败 → 解锁
|
|
||||||
}
|
}
|
||||||
} finally {
|
} catch (e) {
|
||||||
// Hand control back to poll(); busy is left as set above (locked when the
|
await refreshMe()
|
||||||
// job is still rendering after a gateway timeout).
|
task.status = 'failed'
|
||||||
submitting.value = false
|
task.error = String(e)
|
||||||
}
|
}
|
||||||
// Sync real server state: poll() picks up the live pending job (replacing our
|
loadHistory()
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recover the current generation from the server: any pending job for this
|
// Fill the grid up to 10 with the user's recent rows (进行中 + 成功). Past
|
||||||
// user lives in event_log, so reload / parallel tab / parallel browser can
|
// FAILURES are never shown from history — an error is only relevant for the live
|
||||||
// all see the same in-flight state and the same final result.
|
// generation the user just ran. Prune optimistic tasks the server now tracks.
|
||||||
async function poll() {
|
let prevPending = 0
|
||||||
// While run() is mid-submit it fully owns busy/current — don't race it.
|
async function loadHistory() {
|
||||||
if (submitting.value) return
|
// Server-side filter: status IN (pending, success), newest 12 — exactly the
|
||||||
const r = await api('/jobs/mine')
|
// 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
|
if (!r.ok) return
|
||||||
const { pending, latest } = r.data || {}
|
history.value = (r.data?.data || [])
|
||||||
if (pending) {
|
.filter((e) => e.status === 'pending' || e.file)
|
||||||
busy.value = true
|
.map((e) => ({
|
||||||
if (!statusText.value) {
|
id: 'srv-' + e.id,
|
||||||
statusText.value = pending.kind === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
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
|
||||||
}
|
}
|
||||||
if (!current.value || current.value.id !== pending.id) {
|
|
||||||
current.value = { ...pending }
|
// Click a generated IMAGE → use it as a reference. Single-ref model: replace the
|
||||||
// Replay the pending job's params onto the form so a fresh tab shows
|
// existing ref. Multi-ref: append if there's room, else replace the last one.
|
||||||
// what's cooking — and writes them into the cross-component draft.
|
function useAsRef(item) {
|
||||||
applyJobToDraft(pending)
|
if (!item || !item.url || item.status !== 'done') return
|
||||||
mode.value = pending.kind === 'video' ? 'video' : 'image'
|
if (item.kind === 'video') return
|
||||||
modelId.value = pending.model || modelId.value
|
const cap = maxRefs.value
|
||||||
prompt.value = pending.prompt || prompt.value
|
if (cap <= 0) { flash('当前模型不支持参考图'); return }
|
||||||
ratio.value = pending.ratio || ratio.value
|
const ref = { name: 'ref', url: item.url }
|
||||||
resolution.value = pending.resolution || resolution.value
|
if (cap === 1) {
|
||||||
duration.value = pending.duration || duration.value
|
refImages.value = [ref]
|
||||||
// Re-display the uploaded reference image(s) after a reload. They're
|
} else if (refImages.value.length >= cap) {
|
||||||
// served (cookie-authed) from /images; re-fetch into data URLs so the
|
refImages.value.splice(cap - 1, 1, ref)
|
||||||
// thumbnails show AND the refs stay re-submittable if the user regenerates.
|
} else {
|
||||||
restoreRefs(pending.reference_urls)
|
refImages.value.push(ref)
|
||||||
}
|
}
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
// Intentionally NO restore of an already-finished result on first paint /
|
lightbox.value = item
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
||||||
@@ -434,10 +485,10 @@ onMounted(async () => {
|
|||||||
applyModelDefaults()
|
applyModelDefaults()
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
// Restore any in-flight or recently-finished job for this user, then poll
|
// Fill the grid with the user's recent results, then refresh every 3s so
|
||||||
// every 2s so a parallel tab / device sees changes within one tick.
|
// finished tasks (incl. gateway-timed-out ones) land without a reload.
|
||||||
poll()
|
loadHistory()
|
||||||
pollTimer = setInterval(poll, 2000)
|
pollTimer = setInterval(loadHistory, 3000)
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', onKey)
|
window.removeEventListener('keydown', onKey)
|
||||||
@@ -447,18 +498,17 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="theme-text grid lg:grid-cols-[420px_1fr] gap-6">
|
<section class="theme-text grid lg:grid-cols-[420px_1fr] gap-6">
|
||||||
<!-- LEFT: controls — every interactive element accepts :disabled="busy"
|
<!-- LEFT: controls — never locked. 生成 fires an independent task each click,
|
||||||
so the form locks the moment a generation kicks off. Reload, parallel
|
so several generations can run at once (concurrent). -->
|
||||||
tab and tab-switch all see the same locked state via poll(). -->
|
|
||||||
<div class="card p-5 space-y-5 lg:sticky lg:top-24 self-start">
|
<div class="card p-5 space-y-5 lg:sticky lg:top-24 self-start">
|
||||||
<!-- mode switch -->
|
<!-- mode switch -->
|
||||||
<div class="grid grid-cols-2 gap-2 p-1 bg-slate-100 rounded-xl">
|
<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="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'">
|
: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" /> 生图
|
<Icon name="files" class="w-4 h-4 inline -mt-0.5" /> 生图
|
||||||
</button>
|
</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="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'">
|
: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" /> 生视频
|
<Icon name="video" class="w-4 h-4 inline -mt-0.5" /> 生视频
|
||||||
@@ -469,7 +519,7 @@ onUnmounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">模型</label>
|
<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"
|
<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">
|
<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' ? '视频' : '图像' }}模型 ·
|
还没有可用的{{ mode === 'video' ? '视频' : '图像' }}模型 ·
|
||||||
<router-link to="/admin/models" class="text-slate-700 underline">去添加</router-link>
|
<router-link to="/admin/models" class="text-slate-700 underline">去添加</router-link>
|
||||||
@@ -479,7 +529,7 @@ onUnmounted(() => {
|
|||||||
<!-- prompt -->
|
<!-- prompt -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">提示词</label>
|
<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>
|
placeholder="描述想要的画面…如:黄昏时分,金色麦田里奔跑的金毛猎犬,电影感"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -489,7 +539,7 @@ onUnmounted(() => {
|
|||||||
<div v-if="ratios.length > 0 && showRatio">
|
<div v-if="ratios.length > 0 && showRatio">
|
||||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">比例</label>
|
<label class="block text-xs font-medium text-slate-500 mb-1.5">比例</label>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<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="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'">
|
:class="ratio === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
|
||||||
{{ r }}
|
{{ r }}
|
||||||
@@ -500,7 +550,7 @@ onUnmounted(() => {
|
|||||||
<div v-if="resolutions.length > 0">
|
<div v-if="resolutions.length > 0">
|
||||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">{{ mode === 'video' ? '分辨率' : '画质' }}</label>
|
<label class="block text-xs font-medium text-slate-500 mb-1.5">{{ mode === 'video' ? '分辨率' : '画质' }}</label>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<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="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'">
|
:class="resolution === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
|
||||||
{{ r }}
|
{{ r }}
|
||||||
@@ -511,7 +561,7 @@ onUnmounted(() => {
|
|||||||
<div v-if="mode === 'video' && durations.length > 0">
|
<div v-if="mode === 'video' && durations.length > 0">
|
||||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">时长</label>
|
<label class="block text-xs font-medium text-slate-500 mb-1.5">时长</label>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<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="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'">
|
:class="duration === d ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
|
||||||
{{ d }}
|
{{ d }}
|
||||||
@@ -528,12 +578,13 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
<span v-if="refsRequired" class="text-rose-500">*</span>
|
<span v-if="refsRequired" class="text-rose-500">*</span>
|
||||||
</label>
|
</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"
|
<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="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' : ''">
|
|
||||||
<img :src="img.dataUrl || img.url" class="w-full h-full object-cover" />
|
<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">
|
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" />
|
<Icon name="close" class="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -542,20 +593,33 @@ onUnmounted(() => {
|
|||||||
{{ i === 0 ? '首帧' : (i === 1 ? '末帧' : '') }}
|
{{ i === 0 ? '首帧' : (i === 1 ? '末帧' : '') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="refImages.length < maxRefs" type="button" @click="openPicker" :disabled="busy"
|
<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">
|
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" />
|
:title="dragOver ? '松开以添加' : '点击或拖拽图片到此'">
|
||||||
|
<Icon :name="dragOver ? 'download' : 'plus'" class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="onFiles" />
|
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="onFiles" />
|
||||||
</div>
|
</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">
|
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" />
|
<Icon name="spark" class="w-4 h-4 shrink-0" />
|
||||||
<span class="leading-none">{{ busy ? (mode === 'video' ? '生成中…请耐心等待' : '生成中…') : '生成' }}</span>
|
<span class="leading-none">生成<span v-if="batchCount > 1"> {{ batchCount }} 张</span></span>
|
||||||
<span v-if="!busy && price != null" class="text-xs opacity-70 tabular-nums leading-none">· {{ priceLabel }}</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="!busy && price != null && !canAfford" class="text-xs text-rose-200 leading-none">积分不足</span>
|
<span v-if="price != null && !canAfford" class="text-xs text-rose-200 leading-none">积分不足</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Validation / upload errors (model/prompt/ref/price/credits/oversized
|
<!-- Validation / upload errors (model/prompt/ref/price/credits/oversized
|
||||||
@@ -565,59 +629,55 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT: single latest result (replaces on each new generation).
|
<!-- RIGHT: concurrent gallery — one card per task, newest first; filled up to
|
||||||
min-w-0: the 1fr grid track defaults to min-width:auto, so a long
|
10 with the user's recent results. No lock: 生成 can be clicked anytime.
|
||||||
unbroken prompt would otherwise blow the column wider than the page
|
min-w-0 keeps a long prompt from blowing the 1fr track 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="min-w-0">
|
||||||
<div class="text-sm font-medium line-clamp-2 break-words">{{ current.prompt }}</div>
|
<div v-if="displayItems.length" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
<div class="text-[11px] text-slate-400 mt-0.5 font-mono">
|
<div v-for="item in displayItems" :key="item.id"
|
||||||
{{ current.model }} · {{ current.ratio }} · {{ current.resolution }}
|
class="group relative rounded-xl overflow-hidden ring-1 ring-slate-200 bg-slate-100 aspect-[4/5]">
|
||||||
<span v-if="current.kind === 'video'"> · {{ current.duration }}</span>
|
<!-- done: media + caption -->
|
||||||
<span v-if="current.elapsed_ms"> · {{ (current.elapsed_ms / 1000).toFixed(1) }}s</span>
|
<template v-if="item.status === 'done' && item.url">
|
||||||
</div>
|
<video v-if="item.kind === 'video'" :src="item.url" muted loop preload="metadata"
|
||||||
</div>
|
@click="onVideoClick(item)"
|
||||||
<!-- only when a finished result exists — hidden while pending/failed -->
|
:title="refMode === 'frame' && maxRefs > 0 ? '点击:把末帧设为首帧' : '点击放大'"
|
||||||
<div v-if="current.url && current.status !== 'pending' && current.status !== 'failed'"
|
class="absolute inset-0 w-full h-full object-cover cursor-pointer"
|
||||||
class="flex items-center gap-1.5 shrink-0">
|
@mouseenter="$event.target.play && $event.target.play()"
|
||||||
<a :href="current.url" :download="''" class="btn-soft" title="下载">
|
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||||
<Icon name="download" class="w-3.5 h-3.5" />
|
<img v-else :src="item.url" loading="lazy" @click="useAsRef(item)"
|
||||||
</a>
|
:title="maxRefs > 0 ? '点击作为参考图' : ''"
|
||||||
<button @click="copyLink(current.url)" class="btn-soft" title="复制链接">
|
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
<Icon name="copy" class="w-3.5 h-3.5" />
|
: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>
|
</button>
|
||||||
</div>
|
</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>
|
</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>
|
</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>
|
||||||
|
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ onMounted(refresh)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- grid -->
|
<!-- grid -->
|
||||||
<div v-if="loading" 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-white/40 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-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"
|
<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]"
|
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 -->
|
<!-- pagination — shown when there's more than one page worth of entries -->
|
||||||
<div v-if="!loading && totalPages > 1"
|
<div v-if="!loading && totalPages > 1"
|
||||||
class="card !p-3 flex items-center justify-between gap-3">
|
class="card !p-3 flex items-center justify-between gap-3">
|
||||||
<div class="text-xs text-white/55 tabular-nums px-2">
|
<div class="text-xs text-[color:var(--fg-3)] tabular-nums px-2">
|
||||||
<span class="text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span>
|
<span class="text-[color:var(--fg)]">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span>
|
||||||
/ {{ filtered.length }} 条
|
/ {{ filtered.length }} 条
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
<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>
|
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,20 +226,20 @@ onMounted(refresh)
|
|||||||
<!-- ======= form modal ======= -->
|
<!-- ======= form modal ======= -->
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="editing"
|
<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">
|
@click.self="closeForm">
|
||||||
<div class="card w-full max-w-2xl !shadow-2xl">
|
<div class="card w-full max-w-2xl !shadow-2xl my-auto">
|
||||||
<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 class="text-sm font-semibold">
|
||||||
{{ form.id ? '编辑' : '新增' }} ·
|
{{ form.id ? '编辑' : '新增' }} ·
|
||||||
{{ form.kind === 'hero' ? 'Hero 卡片' : form.kind === 'bento' ? 'Bento 灵感' : '作品' }}
|
{{ form.kind === 'hero' ? 'Hero 卡片' : form.kind === 'bento' ? 'Bento 灵感' : '作品' }}
|
||||||
</h2>
|
</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" />
|
<Icon name="close" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
|
<div class="p-5 space-y-4">
|
||||||
<!-- live preview -->
|
<!-- live preview -->
|
||||||
<div class="relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[5/2] bg-white/[0.04]"
|
<div class="relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[5/2] bg-white/[0.04]"
|
||||||
:style="bgFor(form.image)">
|
:style="bgFor(form.image)">
|
||||||
@@ -256,7 +256,7 @@ onMounted(refresh)
|
|||||||
|
|
||||||
<div class="grid sm:grid-cols-2 gap-3">
|
<div class="grid sm:grid-cols-2 gap-3">
|
||||||
<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>
|
||||||
<div class="flex gap-1.5">
|
<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 = '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>
|
<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>
|
</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" />
|
<input v-model.number="form.weight" type="number" class="field" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- image picker (the central change — admins pick a real image) -->
|
<!-- image picker (the central change — admins pick a real image) -->
|
||||||
<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>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input v-model="form.image" class="field font-mono text-[11px]"
|
<input v-model="form.image" class="field font-mono text-[11px]"
|
||||||
placeholder="user/abc.png 或 https://…" />
|
placeholder="user/abc.png 或 https://…" />
|
||||||
<button type="button" @click="openPicker" class="btn-soft shrink-0">选择已生成</button>
|
<button type="button" @click="openPicker" class="btn-soft shrink-0">选择已生成</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<template v-if="form.kind !== 'work'">
|
<template v-if="form.kind !== 'work'">
|
||||||
<div class="grid sm:grid-cols-2 gap-3">
|
<div class="grid sm:grid-cols-2 gap-3">
|
||||||
<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.title" class="field" placeholder="电影感人物" />
|
<input v-model="form.title" class="field" placeholder="电影感人物" />
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
<input v-model="form.subtitle" class="field" placeholder="CINEMATIC PORTRAIT" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
<textarea v-model="form.prompt" rows="3" class="field resize-none"
|
||||||
placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea>
|
placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<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="form.title" class="field" placeholder="留空则只展示图片" />
|
<input v-model="form.title" class="field" placeholder="留空则只展示图片" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="form.kind === 'bento'">
|
<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">
|
<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"
|
<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="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-white text-slate-900' : 'bg-white/[0.04] text-white/70'">
|
: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' }}
|
{{ s || '默认 1×1' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input v-model="form.span" class="field font-mono text-[11px]" placeholder="md:col-span-2" />
|
<input v-model="form.span" class="field font-mono text-[11px]" placeholder="md:col-span-2" />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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="closeForm" class="btn-ghost">取消</button>
|
||||||
<button @click="save" :disabled="saving" class="btn-primary">
|
<button @click="save" :disabled="saving" class="btn-primary">
|
||||||
{{ saving ? '保存中…' : '保存' }}
|
{{ 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"
|
class="fixed inset-0 z-[60] bg-black/80 backdrop-blur-sm grid place-items-center p-4"
|
||||||
@click.self="closePicker">
|
@click.self="closePicker">
|
||||||
<div class="card w-full max-w-4xl !shadow-2xl">
|
<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>
|
<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" />
|
<Icon name="close" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 max-h-[70vh] overflow-y-auto">
|
<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">
|
<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)"
|
<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">
|
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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.filter-pill {
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background: rgb(255 255 255 / 0.06);
|
background: var(--surface-2);
|
||||||
color: rgb(255 255 255 / 0.65);
|
color: var(--fg-2);
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.filter-pill:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
.filter-pill:hover { background: var(--hover); color: var(--fg); }
|
||||||
.filter-pill.on { background: white; color: rgb(15 23 42); }
|
.filter-pill.on { background: var(--btn-solid-bg); color: var(--btn-solid-fg); }
|
||||||
|
|
||||||
.kind-btn {
|
.kind-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
background: rgb(255 255 255 / 0.06);
|
background: var(--surface-2);
|
||||||
color: rgb(255 255 255 / 0.7);
|
color: var(--fg-2);
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.kind-btn:hover { background: rgb(255 255 255 / 0.1); }
|
.kind-btn:hover { background: var(--hover); }
|
||||||
.kind-btn.on { background: white; color: rgb(15 23 42); }
|
.kind-btn.on { background: var(--btn-solid-bg); color: var(--btn-solid-fg); }
|
||||||
|
|
||||||
.field {
|
/* No scoped .field — use the GLOBAL .field (bg-white + border-slate-200 in light,
|
||||||
width: 100%;
|
.public-dark .field in dark) so inputs match every other modal and the border
|
||||||
padding: 0.5rem 0.7rem;
|
is clearly visible. A scoped override here only re-broke the border. */
|
||||||
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); }
|
|
||||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||||
|
|
||||||
@@ -403,15 +397,15 @@ onMounted(refresh)
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
color: rgb(255 255 255 / 0.7);
|
color: var(--fg-2);
|
||||||
background: rgb(255 255 255 / 0.04);
|
background: var(--surface-2);
|
||||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||||
transition: background 0.15s, color 0.15s;
|
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 {
|
.pg-on {
|
||||||
background: rgb(255 255 255 / 0.92);
|
background: var(--btn-solid-bg);
|
||||||
color: rgb(15 23 42);
|
color: var(--btn-solid-fg);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { api, generatedUrl } from '../api'
|
import { api, generatedUrl } from '../api'
|
||||||
import { fmtDate, fmtClock, fmtTs } from '../utils/format'
|
import { fmtDate, fmtClock, fmtTs } from '../utils/format'
|
||||||
|
import { copyText } from '../utils/clipboard'
|
||||||
import { points } from '../credits'
|
import { points } from '../credits'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||||
@@ -23,6 +24,15 @@ const page = ref(1)
|
|||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
const lightbox = ref(null)
|
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"。
|
// 来源筛选走服务端:画图台 = source "user",API = source "v1"。
|
||||||
const SOURCE_PARAM = { web: 'user', api: 'v1' }
|
const SOURCE_PARAM = { web: 'user', api: 'v1' }
|
||||||
|
|
||||||
@@ -230,7 +240,10 @@ const params = (e) => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3 align-middle min-w-0">
|
<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>
|
<div v-if="e.error" class="mt-1 text-[11px] text-rose-600 truncate" :title="e.error">⚠ {{ e.error }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3 align-middle text-xs text-slate-500 tabular-nums">{{ params(e) || '—' }}</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(' · ')"
|
:meta="[lightbox.model, lightbox.ratio, lightbox.resolution, lightbox.duration].filter(Boolean).join(' · ')"
|
||||||
:download-name="lightbox.file"
|
:download-name="lightbox.file"
|
||||||
@close="lightbox = null" />
|
@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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { api, generatedUrl } from '../api'
|
import { api, generatedUrl } from '../api'
|
||||||
import { fmtTs } from '../utils/format'
|
import { fmtTs } from '../utils/format'
|
||||||
|
import { copyText } from '../utils/clipboard'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||||
|
|
||||||
@@ -80,12 +81,17 @@ function fmtMs(ms) {
|
|||||||
|
|
||||||
|
|
||||||
async function copyLink(name) {
|
async function copyLink(name) {
|
||||||
try {
|
|
||||||
const u = generatedUrl(name)
|
const u = generatedUrl(name)
|
||||||
await navigator.clipboard.writeText(u.startsWith('http') ? u : location.origin + u)
|
const ok = await copyText(u.startsWith('http') ? u : location.origin + u)
|
||||||
toast.value = '链接已复制'
|
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)
|
setTimeout(() => (toast.value = ''), 1500)
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toast = ref('')
|
const toast = ref('')
|
||||||
@@ -198,7 +204,10 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- caption (over a real image) -->
|
<!-- 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 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">
|
<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="truncate" :title="e.model || ''">{{ e.model || '—' }}</span>
|
||||||
<span class="shrink-0 flex items-center gap-1">
|
<span class="shrink-0 flex items-center gap-1">
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const showAdd = ref(false)
|
|||||||
const editing = ref(null)
|
const editing = ref(null)
|
||||||
const toast = ref('')
|
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 = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: 'active', label: '正常' },
|
{ value: 'active', label: '正常' },
|
||||||
@@ -100,7 +100,7 @@ async function createUser() {
|
|||||||
const r = await api('/users', jsonBody('POST', addForm.value))
|
const r = await api('/users', jsonBody('POST', addForm.value))
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
showAdd.value = false
|
showAdd.value = false
|
||||||
addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0 }
|
addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0, notes: '' }
|
||||||
flash('用户已创建')
|
flash('用户已创建')
|
||||||
load()
|
load()
|
||||||
} else flash(r.data?.detail || '创建失败')
|
} else flash(r.data?.detail || '创建失败')
|
||||||
@@ -115,6 +115,7 @@ async function saveEdit() {
|
|||||||
status: u.status,
|
status: u.status,
|
||||||
credits: u.credits,
|
credits: u.credits,
|
||||||
role: u.role,
|
role: u.role,
|
||||||
|
notes: u.notes || '',
|
||||||
}
|
}
|
||||||
if (u._newPassword) patch.password = u._newPassword
|
if (u._newPassword) patch.password = u._newPassword
|
||||||
const r = await api(`/users/${u.id}`, jsonBody('PATCH', patch))
|
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-9" /> <!-- select -->
|
||||||
<col class="w-40" /> <!-- username -->
|
<col class="w-40" /> <!-- username -->
|
||||||
<col /> <!-- email (flex) -->
|
<col /> <!-- email (flex) -->
|
||||||
|
<col class="w-36" /> <!-- notes -->
|
||||||
<col class="w-20" /> <!-- role -->
|
<col class="w-20" /> <!-- role -->
|
||||||
<col class="w-16" /> <!-- status switch -->
|
<col class="w-16" /> <!-- status switch -->
|
||||||
<col class="w-24" /> <!-- credits -->
|
<col class="w-24" /> <!-- credits -->
|
||||||
@@ -261,6 +263,7 @@ async function quickCredits(u, delta) {
|
|||||||
</th>
|
</th>
|
||||||
<th class="text-left px-5 py-3 font-medium">用户名</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-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>
|
<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">
|
<td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email">
|
||||||
{{ u.email || '—' }}
|
{{ u.email || '—' }}
|
||||||
</td>
|
</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">
|
<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"
|
<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'
|
:class="u.role === 'admin'
|
||||||
@@ -390,6 +396,10 @@ async function quickCredits(u, delta) {
|
|||||||
<label class="lbl">角色</label>
|
<label class="lbl">角色</label>
|
||||||
<SelectMenu v-model="addForm.role" :options="ROLE_OPTIONS" />
|
<SelectMenu v-model="addForm.role" :options="ROLE_OPTIONS" />
|
||||||
</div>
|
</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">
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
<button @click="showAdd = false" class="btn-soft">取消</button>
|
<button @click="showAdd = false" class="btn-soft">取消</button>
|
||||||
<button @click="createUser" class="btn-primary">创建</button>
|
<button @click="createUser" class="btn-primary">创建</button>
|
||||||
@@ -434,6 +444,10 @@ async function quickCredits(u, delta) {
|
|||||||
<label class="lbl">积分</label>
|
<label class="lbl">积分</label>
|
||||||
<input v-model.number="editing.credits" type="number" min="0" step="1" class="field" />
|
<input v-model.number="editing.credits" type="number" min="0" step="1" class="field" />
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="lbl">重置密码 <span class="text-white/35">(留空保持不变)</span></label>
|
<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" />
|
<input v-model="editing._newPassword" type="password" class="field" placeholder="新密码(8-24位,含大小写/数字/符号)" autocomplete="new-password" />
|
||||||
|
|||||||
Reference in New Issue
Block a user