feat: 二次元前端改版 + 后端账号plan探测/调度修复

This commit is contained in:
2026-08-06 10:11:05 +08:00
parent 7f4701d362
commit f76ecd5769
37 changed files with 2484 additions and 813 deletions
+14 -16
View File
@@ -2,18 +2,18 @@ module backend
go 1.26.0
replace github.com/quic-go/qpack => github.com/quic-go/qpack v0.5.1
require (
github.com/bogdanfinn/fhttp v0.6.8
github.com/bogdanfinn/tls-client v1.11.2
github.com/bogdanfinn/tls-client v1.15.1
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.1
github.com/dop251/goja v0.0.0-20260701091749-b07b74453ea9
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/redis/go-redis/v9 v9.16.0
golang.org/x/crypto v0.51.0
golang.org/x/crypto v0.54.0
golang.org/x/image v0.43.0
gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0
@@ -23,13 +23,14 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bogdanfinn/quic-go-utls v1.0.4-utls // indirect
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/bdandy/go-socks4 v1.2.3 // indirect
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
github.com/chromedp/chromedp v0.15.1 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
@@ -62,19 +63,16 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.61.0 // indirect
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.2 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
)
+36 -22
View File
@@ -4,14 +4,20 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4=
github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
github.com/bogdanfinn/quic-go-utls v1.0.4-utls h1:zPjusVVNeJFA2ORMAP0rjnrZrBkV4Dnia4e6ToOfUDA=
github.com/bogdanfinn/quic-go-utls v1.0.4-utls/go.mod h1:UONJOaHGWho08kZtkkgH7GjktEPjMemGxjTcNpVPZVA=
github.com/bogdanfinn/tls-client v1.11.2 h1:o6qX0L1cEi+4MaBqujxqOeK254VZM20t3QR+A34/V6I=
github.com/bogdanfinn/tls-client v1.11.2/go.mod h1:qQIsVGe35NdxYEozNh9JuDZ+aOaOEq2tKAsu2iYEGZg=
github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s=
github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg=
github.com/bogdanfinn/tls-client v1.15.1 h1:KiFAlED55DJ8Fcocn+/1nX6PrDFcttIHAf/GDkV6KN8=
github.com/bogdanfinn/tls-client v1.15.1/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM=
github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -101,6 +107,8 @@ github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uq
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
@@ -116,14 +124,18 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -148,23 +160,25 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -342,6 +342,7 @@ func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
// 「删除异常账号」to collect every dead id regardless of the current page.
typeFilter := strings.TrimSpace(c.Query("type"))
statusFilter := strings.TrimSpace(c.Query("status"))
planFilter := strings.TrimSpace(c.Query("plan"))
deadOnly := c.Query("dead") == "1"
q := strings.ToLower(strings.TrimSpace(c.Query("q")))
filtered := make([]map[string]any, 0, len(data))
@@ -355,6 +356,22 @@ func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
if statusFilter != "" && rowStr(row, "status") != statusFilter {
continue
}
// plan filter: vip=非free, free=free, sub=子号, master=vip且非子号
if planFilter != "" {
plan := strings.ToLower(rowStr(row, "plan"))
isSub := rowBool(row, "sub_account")
isFree := plan == "" || plan == "free"
switch planFilter {
case "vip":
if isFree { continue }
case "free":
if !isFree { continue }
case "sub":
if !isSub { continue }
case "master":
if isFree || isSub { continue }
}
}
if q != "" {
email := strings.ToLower(rowStr(row, "email"))
id := strings.ToLower(rowStr(row, "id"))
@@ -68,6 +68,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) {
Resolution string `json:"resolution"`
Duration string `json:"duration"`
ReferenceImages []string `json:"reference_images"`
ReferenceMode string `json:"reference_mode"`
DeAI bool `json:"deai"`
}
if err := c.ShouldBindJSON(&body); err != nil {
@@ -82,6 +83,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) {
Resolution: body.Resolution,
Duration: body.Duration,
ReferenceImages: body.ReferenceImages,
ReferenceMode: body.ReferenceMode,
DeAI: body.DeAI,
})
if err != nil {
@@ -345,7 +347,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"durations": []string{"4s", "6s", "8s"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"max_reference_images": 2,
"max_reference_images": 9,
"reference_mode": "frame",
},
{
@@ -356,7 +358,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"durations": []string{"4s", "6s", "8s"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"max_reference_images": 2,
"max_reference_images": 9,
"reference_mode": "frame",
},
{
@@ -369,7 +371,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"resolutions": []string{"720p"},
},
{
"key": "seedance-fast",
"key": "seedance-2.0-fast",
"label": "Seedance 2.0 Fast",
"type": "video",
"provider": "adobe",
@@ -377,7 +379,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"max_reference_images": 9,
"reference_mode": "frame",
"reference_mode": "style",
},
{
"key": "seedance-2.0",
@@ -388,7 +390,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"max_reference_images": 9,
"reference_mode": "frame",
"reference_mode": "style",
},
{
"key": "firefly-video",
@@ -398,7 +400,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"durations": []string{"5s"},
"ratios": []string{"16:9", "1:1", "9:16"},
"resolutions": []string{"540p", "720p", "1080p"},
"max_reference_images": 2,
"max_reference_images": 9,
"reference_mode": "frame",
},
{
@@ -524,7 +526,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"4s", "6s", "8s"},
"max_reference_images": 2,
"max_reference_images": 3,
"reference_mode": "frame",
"description": "Veo 3.1 Fast",
},
@@ -533,17 +535,18 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"4s", "6s", "8s"},
"max_reference_images": 0,
"max_reference_images": 3,
"reference_mode": "style",
"description": "Veo 3.1",
},
{
"id": "firefly-ray",
"id": "gemini-veo3.1",
"provider": "adobe",
"type": "video",
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
"resolutions": []string{"720p"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"5s", "10s"},
"max_reference_images": 2,
"reference_mode": "frame",
@@ -561,14 +564,14 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"description": "Adobe Firefly Video",
},
{
"id": "seedance-fast",
"id": "seedance-2.0-fast",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"max_reference_images": 9,
"reference_mode": "frame",
"reference_mode": "style",
"description": "Seedance 2.0 Fast",
},
{
@@ -579,7 +582,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"resolutions": []string{"720p", "1080p"},
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"max_reference_images": 9,
"reference_mode": "frame",
"reference_mode": "style",
"description": "Seedance 2.0",
},
{
@@ -763,7 +766,7 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"stub": false,
},
{
"id": "seedance-fast",
"id": "seedance-2.0-fast",
"provider": "adobe",
"kind": "video",
"ratios": []string{"16:9", "9:16"},
+6 -4
View File
@@ -129,10 +129,10 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
h.writeAuthError(c, err)
return
}
var modelID, prompt, seconds, size string
var modelID, prompt, seconds, size, referenceMode string
var refs []string
if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
if err := c.Request.ParseMultipartForm(64 << 20); err != nil {
if err := c.Request.ParseMultipartForm(256 << 20); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
return
}
@@ -140,6 +140,7 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
prompt = c.PostForm("prompt")
seconds = c.PostForm("seconds")
size = c.PostForm("size")
referenceMode = c.PostForm("reference_mode")
refs = readMultipartImages(c, "input_reference", "input_reference[]")
} else {
var body struct {
@@ -147,8 +148,7 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
Prompt string `json:"prompt"`
Seconds json.RawMessage `json:"seconds"`
Size string `json:"size"`
// Reference frames (image-to-video / first-last frames) as base64 or
// data-URI strings — the JSON equivalent of multipart input_reference.
ReferenceMode string `json:"reference_mode"`
InputReference []string `json:"input_reference"`
ReferenceImages []string `json:"reference_images"`
}
@@ -158,6 +158,7 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
}
modelID, prompt, size = body.Model, body.Prompt, body.Size
seconds = rawToString(body.Seconds)
referenceMode = strings.TrimSpace(body.ReferenceMode)
refs = append(body.InputReference, body.ReferenceImages...)
}
duration := strings.TrimSpace(seconds)
@@ -172,6 +173,7 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
AspectRatio: aspect,
Resolution: resolution,
ReferenceImages: refs,
ReferenceMode: referenceMode,
BaseURL: requestBaseURL(c),
})
if err != nil {
+1
View File
@@ -125,6 +125,7 @@ type ModelConfig struct {
Alias string `gorm:"column:alias;size:255;not null;default:''"`
Provider string `gorm:"size:100;index;not null"`
Enabled bool `gorm:"not null;default:true"`
FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用
Ratios datatypes.JSON `gorm:"type:jsonb"`
Prices datatypes.JSONMap `gorm:"type:jsonb"`
Resolutions datatypes.JSON `gorm:"type:jsonb"`
+5
View File
@@ -11,6 +11,11 @@ const (
refreshURL = "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"
clientID = "projectx_webapp"
scopeValue = "AdobeID,firefly_api,openid"
// clioClientID / clioScopeValue mint the firefly.adobe.com token that is
// entitled to the Firefly-native seedance models (creative_production scope).
// Unlike the Express token this exchange requires user_id and no guest_allowed.
clioClientID = "clio-playground-web"
clioScopeValue = "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext,additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations,additional_info.roles,account_cluster.read,creative_production,tk_platform,tk_platform_sync,profile"
)
var ErrAdobeCookieEmpty = errors.New("cookie is empty")
+173 -75
View File
@@ -28,6 +28,10 @@ const (
fireflyVideoSubmitURL = "https://video-v1.ff.adobe.io/v2/videos/generate"
fireflyVideoUploadURL = "https://video-v1.ff.adobe.io/v2/storage/image"
uploadURL = "https://firefly-3p.ff.adobe.io/v2/storage/image"
// Seedance reference video/audio go to their own storage endpoints; posting
// them to /v2/storage/image is rejected.
uploadVideoURL = "https://firefly-3p.ff.adobe.io/v2/storage/video"
uploadAudioURL = "https://firefly-3p.ff.adobe.io/v2/storage/audio"
creditsURL = "https://firefly.adobe.io/v1/credits/balance"
creditsAPIKey = "SunbreakWebUI1"
)
@@ -43,14 +47,24 @@ var (
// the account's — every account rejects the same content — so the caller must
// surface it as-is without penalizing/killing the account or failing over.
ErrContentRejected = errors.New("adobe content rejected")
// ErrNotEntitled is a 403 user_not_entitled on submit: the account has no
// Firefly entitlement at all. It wraps ErrAuth so existing auth handling still
// applies, but callers can single it out — unlike an expired access token this
// cannot be fixed by refreshing from the cookie, so the account is done.
ErrNotEntitled = fmt.Errorf("%w: user not entitled", ErrAuth)
)
// isContentRejection reports whether an Adobe response (status + body) is a
// content-safety refusal rather than a genuine upstream/account failure. Adobe
// returns HTTP 451 with an "*_unsafe" error_code when moderation blocks the
// prompt or the produced image.
// prompt or the produced image, and "reference_image_privacy_error" when a
// reference image contains a real person's face. Both are the request's fault —
// every account rejects them identically — so neither may penalize the account.
func isContentRejection(status int, body string) bool {
return status == 451 && strings.Contains(body, "unsafe")
if status != 451 {
return false
}
return strings.Contains(body, "unsafe") || strings.Contains(body, "privacy_error")
}
var profileURLs = []string{
@@ -61,7 +75,6 @@ var profileURLs = []string{
type Client struct {
apiKey string
proxy string
arpSessionID string // cached per-client, reused across requests (matches adobe2api)
}
func NewClient(apiKey, proxy string) *Client {
@@ -71,16 +84,11 @@ func NewClient(apiKey, proxy string) *Client {
}
}
// getARPSessionID returns a cached ARP session id matching adobe2api's format:
// base64({"sid":"<uuid>","ftr":"<hex16>_<ts_ms>_<pid>_dUAL43-mnts-ants-d4_31ck__tt"})
// Generated once per client and reused — adobe2api reuses the same session id per
// token/profile instead of rotating every request.
func (c *Client) getARPSessionID() string {
if c.arpSessionID != "" {
return c.arpSessionID
}
c.arpSessionID = buildARPSessionID()
return c.arpSessionID
// getARPSessionID returns a fresh ARP session id for this token. The value is
// regenerated per call, but the embedded pid stays stable per token; see
// buildARPSessionID for the wire format.
func (c *Client) getARPSessionID(token string) string {
return buildARPSessionID(token)
}
func (c *Client) SetProxy(proxy string) {
@@ -116,19 +124,36 @@ func (c *Client) UploadImage(ctx context.Context, token string, content []byte,
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return "", err
return "", fmt.Errorf("adobe upload bad response: %w (%s)", err, clip(body, 300))
}
if images, ok := payload["images"].([]any); ok && len(images) > 0 {
if first, ok := images[0].(map[string]any); ok {
if id := strings.TrimSpace(stringValue(first["id"])); id != "" {
if id := blobIDFromUploadPayload(payload); id != "" {
return id, nil
}
}
}
return "", fmt.Errorf("adobe upload missing blob id: %s", clip(body, 300))
}
// blobIDFromUploadPayload digs the blob id out of a storage response. The image
// endpoint answers {"images":[{"id":...}]}, video/audio use their own key
// ("videos"/"audios"/"assets"/"files"), and some responses put the id at the top
// level — accept any of them instead of only "images".
func blobIDFromUploadPayload(payload map[string]any) string {
if id := strings.TrimSpace(stringValue(payload["id"])); id != "" {
return id, nil
return id
}
return "", errors.New("adobe upload missing blob id")
for _, key := range []string{"images", "videos", "audios", "audio", "assets", "files"} {
items, ok := payload[key].([]any)
if !ok || len(items) == 0 {
continue
}
first, ok := items[0].(map[string]any)
if !ok {
continue
}
if id := strings.TrimSpace(stringValue(first["id"])); id != "" {
return id
}
}
return ""
}
// uploadImageOnce performs a single upload attempt and returns the raw response
@@ -141,8 +166,19 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by
}
endpoint := uploadURL
if engine == "firefly-video" {
switch {
case engine == "firefly-video":
endpoint = fireflyVideoUploadURL
case strings.HasPrefix(contentType, "video/"):
endpoint = uploadVideoURL
case strings.HasPrefix(contentType, "audio/"):
endpoint = uploadAudioURL
}
// Seedance is a Firefly-native model: its uploads need the clio-playground-web
// key, the express key is rejected with 403 access_error.
apiKey := c.apiKey
if strings.HasPrefix(engine, "seedance-") {
apiKey = clioClientID
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(content))
if err != nil {
@@ -151,7 +187,7 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by
req = req.WithContext(ctx)
req.Header = http.Header{
"authorization": {"Bearer " + strings.TrimSpace(token)},
"x-api-key": {c.apiKey},
"x-api-key": {apiKey},
"content-type": {defaultString(contentType, "image/png")},
"accept": {"*/*"},
"user-agent": {sess.fp.userAgent},
@@ -233,24 +269,36 @@ func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspe
// Content-safety refusal: the prompt/image is blocked, retrying other payloads
// or accounts is pointless — surface it as-is so the pool doesn't fail over.
if errors.Is(lastErr, ErrContentRejected) {
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrContentRejected, clip(lastBody, 300))
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrContentRejected, submitDetail(lastErr, lastBody))
}
// Preserve the temporary classification so the pool retries (overload / 5xx /
// rate-limit) instead of failing the request outright.
if errors.Is(lastErr, ErrDeadUpstream) {
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrDeadUpstream, clip(lastBody, 300))
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrDeadUpstream, submitDetail(lastErr, lastBody))
}
if errors.Is(lastErr, ErrTemporaryUpstream) {
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrTemporaryUpstream, clip(lastBody, 300))
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrTemporaryUpstream, submitDetail(lastErr, lastBody))
}
return nil, nil, fmt.Errorf("adobe submit failed: %s", clip(lastBody, 300))
return nil, nil, fmt.Errorf("adobe submit failed: %s", submitDetail(lastErr, lastBody))
}
// submitDetail 拼接上游细节:优先用响应体,响应体为空时(传输层报错,例如超时、
// 连接被切)退回内层错误文本,避免错误只剩一句 "adobe submit:" 无从排查。
func submitDetail(err error, body []byte) string {
if s := clip(body, 300); strings.TrimSpace(s) != "" {
return s
}
if err != nil {
return err.Error()
}
return "(empty response)"
}
// GenerateVideo renders the clip and (when downloadResult) downloads the MP4.
// With downloadResult=false it returns nil bytes and the upstream presigned URL
// in meta["video_url"] — used by the async /v1/videos job, which proxies that URL
// on /content instead of persisting the file.
func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string, downloadResult bool) ([]byte, map[string]any, error) {
func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs, videoBlobIDs, audioBlobIDs []string, downloadResult bool) ([]byte, map[string]any, error) {
// Only the submit goes through the proxy; polling + download run on the local IP.
submitSess, err := c.newTLSClient()
if err != nil {
@@ -261,7 +309,7 @@ func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspec
return nil, nil, err
}
payload := BuildVideoPayload(engine, prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs)
payload := BuildVideoPayload(engine, prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs, videoBlobIDs, audioBlobIDs)
endpoint := videoSubmitURL
if engine == "firefly-video" {
endpoint = fireflyVideoSubmitURL
@@ -368,7 +416,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
}, nil
}
sess, err := c.newDirectTLSClient()
sess, err := c.newTLSClient()
if err != nil {
return nil, err
}
@@ -444,6 +492,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
"used": intOrNil(quota["used"]),
"total": intOrNil(quota["total"]),
"available_until": emptyStringNil(strings.TrimSpace(stringValue(totalInfo["availableUntil"]))),
"plan": strings.TrimSpace(stringValue(totalInfo["planCap"])),
"unknown": false,
"error": nil,
}, nil
@@ -462,6 +511,7 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
req.Header = http.Header{
"authorization": {"Bearer " + strings.TrimSpace(token)},
"x-api-key": {c.apiKey},
"x-arp-session-id": {c.getARPSessionID(token)},
"content-type": {"application/json"},
"accept": {"*/*"},
"origin": {"https://new.express.adobe.com"},
@@ -477,6 +527,7 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
http.HeaderOrderKey: {
"authorization",
"x-api-key",
"x-arp-session-id",
"content-type",
"accept",
"origin",
@@ -506,10 +557,14 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
return nil, "", err
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") {
accessErr := resp.Header.Get("x-access-error")
if strings.EqualFold(accessErr, "taste_exhausted") {
return respBody, "", ErrQuotaExhausted
}
return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300))
if strings.EqualFold(accessErr, "user_not_entitled") {
return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrNotEntitled, resp.StatusCode, accessErr, clip(respBody, 300))
}
return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrAuth, resp.StatusCode, accessErr, clip(respBody, 300))
}
// "system under load" / timeout_error = adobe rate-limit/overload (can come on a
// non-5xx) — treat as temporary so the pool retries instead of failing.
@@ -520,10 +575,10 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
return respBody, "", ErrContentRejected
}
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return respBody, "", ErrDeadUpstream
return respBody, "", fmt.Errorf("%w (submit %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(respBody, 300))
}
if resp.StatusCode != 200 {
return respBody, "", errors.New("submit rejected")
return respBody, "", fmt.Errorf("submit rejected: %d %s", resp.StatusCode, clip(respBody, 300))
}
var payloadResp map[string]any
@@ -588,7 +643,7 @@ func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL
return nil, nil, fmt.Errorf("%w: %s", ErrContentRejected, clip(body, 300))
}
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return nil, nil, ErrDeadUpstream
return nil, nil, fmt.Errorf("%w (poll %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(body, 300))
}
if resp.StatusCode != 200 {
return nil, nil, fmt.Errorf("adobe poll failed: %d %s", resp.StatusCode, clip(body, 300))
@@ -635,13 +690,23 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo
return nil, "", err
}
req = req.WithContext(ctx)
// Seedance is a Firefly-native model and needs the clio-playground-web key;
// the express key is rejected with 403 access_error. The origin stays on the
// Express surface for every engine — the upstream only gates on the key and
// x-arp-session-id, not on origin.
apiKey := c.apiKey
origin := "https://new.express.adobe.com"
if strings.EqualFold(stringValue(payload["modelId"]), "seedance") {
apiKey = clioClientID
}
req.Header = http.Header{
"authorization": {"Bearer " + strings.TrimSpace(token)},
"x-api-key": {c.apiKey},
"x-api-key": {apiKey},
"x-arp-session-id": {c.getARPSessionID(token)},
"content-type": {"application/json"},
"accept": {"*/*"},
"origin": {"https://new.express.adobe.com"},
"referer": {"https://new.express.adobe.com/"},
"origin": {origin},
"referer": {origin + "/"},
"accept-language": {"en-US,en;q=0.9"},
"sec-ch-ua": {sess.fp.secCHUA},
"sec-ch-ua-mobile": {"?0"},
@@ -653,6 +718,7 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo
http.HeaderOrderKey: {
"authorization",
"x-api-key",
"x-arp-session-id",
"content-type",
"accept",
"origin",
@@ -686,18 +752,22 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo
return nil, "", err
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") {
accessErr := resp.Header.Get("x-access-error")
if strings.EqualFold(accessErr, "taste_exhausted") {
return respBody, "", ErrQuotaExhausted
}
if strings.EqualFold(accessErr, "user_not_entitled") {
return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrNotEntitled, resp.StatusCode, accessErr, clip(respBody, 300))
}
// Surface Adobe's response body — "adobe auth failed" alone hides whether
// it's a bad token, a missing scope, or a WAF/fingerprint block.
return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300))
return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, accessErr, clip(respBody, 300))
}
if isContentRejection(resp.StatusCode, string(respBody)) {
return respBody, "", ErrContentRejected
}
if resp.StatusCode == 408 || resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return respBody, "", ErrDeadUpstream
return respBody, "", fmt.Errorf("%w (video submit %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(respBody, 300))
}
// "system under load" / timeout_error = adobe overload — treat as a temporary
// error so the tempFailover policy moves to the next account (same as the image path).
@@ -773,7 +843,7 @@ func (c *Client) pollVideo(ctx context.Context, sess *tlsSession, token, pollURL
return nil, nil, fmt.Errorf("%w: %s", ErrContentRejected, clip(body, 300))
}
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return nil, nil, ErrDeadUpstream
return nil, nil, fmt.Errorf("%w (video poll %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(body, 300))
}
if resp.StatusCode != 200 {
return nil, nil, fmt.Errorf("adobe video poll failed: %d %s", resp.StatusCode, clip(body, 300))
@@ -889,7 +959,12 @@ const (
osMac
)
func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int) fingerprint {
// newChromeFingerprint pairs a TLS ClientProfile with headers for the same
// Chrome major. secCHUA is passed in verbatim rather than templated: the GREASE
// brand entry ("Not(A:Brand", "Not_A Brand", …), its version, and its position
// in the list all rotate per Chrome release, so there is no formula to derive
// it from the major.
func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int, secCHUA string) fingerprint {
ua := fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", major)
platform := `"Windows"`
if osKind == osMac {
@@ -899,7 +974,7 @@ func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int) fin
return fingerprint{
profile: profile,
userAgent: ua,
secCHUA: fmt.Sprintf(`"Not?A_Brand";v="99", "Google Chrome";v="%d", "Chromium";v="%d"`, major, major),
secCHUA: secCHUA,
platform: platform,
}
}
@@ -908,12 +983,21 @@ func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int) fin
// Windows/macOS. Each request draws one at random so the outbound TLS
// fingerprint and headers vary from call to call instead of presenting a single
// static signature to Adobe's anti-bot layer.
//
// The majors here are capped by what tls-client ships a TLS profile for
// (Chrome_146 is the newest in v1.15.1). Live Adobe traffic is on Chromium
// 150+, so the UA is deliberately NOT set higher than the pool: advertising a
// newer UA than the JA3/JA4 handshake backs up is a sharper signal than being a
// few releases behind. tls-client has no Edge profile at any version, so the
// pool stays pure Chrome rather than pairing an Edge UA with a Chrome
// handshake.
var adobeFingerprints = []fingerprint{
newChromeFingerprint(profiles.Chrome_133, 133, osWindows),
newChromeFingerprint(profiles.Chrome_131, 131, osWindows),
newChromeFingerprint(profiles.Chrome_124, 124, osWindows),
newChromeFingerprint(profiles.Chrome_133, 133, osMac),
newChromeFingerprint(profiles.Chrome_131, 131, osMac),
newChromeFingerprint(profiles.Chrome_146, 146, osWindows, `"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"`),
newChromeFingerprint(profiles.Chrome_146, 146, osMac, `"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"`),
newChromeFingerprint(profiles.Chrome_144, 144, osWindows, `"Chromium";v="144", "Google Chrome";v="144", "Not?A_Brand";v="24"`),
newChromeFingerprint(profiles.Chrome_144, 144, osMac, `"Chromium";v="144", "Google Chrome";v="144", "Not?A_Brand";v="24"`),
newChromeFingerprint(profiles.Chrome_133, 133, osWindows, `"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"`),
newChromeFingerprint(profiles.Chrome_131, 131, osMac, `"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"`),
}
func randomFingerprint() fingerprint {
@@ -962,14 +1046,41 @@ func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie s
return nil, ErrAdobeCookieEmpty
}
// Prefer user_id (HAR behavior); fall back to guest_allowed for first-time login.
userID := extractUserIDFromCookie(cookie)
var body string
if userID != "" {
body = "client_id=" + clientID + "&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C") + "&user_id=" + url.QueryEscape(userID)
} else {
body = "client_id=" + clientID + "&guest_allowed=true&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C")
body := "client_id=" + clientID + "&guest_allowed=true&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C")
payload, err := doCookieExchange(ctx, sess, cookie, body, "https://new.express.adobe.com")
if err != nil {
return nil, err
}
token := strings.TrimSpace(stringValue(payload["access_token"]))
if token == "" {
return nil, errors.New("adobe cookie exchange missing access_token")
}
// The Express token (projectx_webapp) covers image/veo/luma/sora but is not
// entitled to the Firefly-native seedance models — those return 403
// model_not_authorized. firefly.adobe.com mints a second token for
// client_id=clio-playground-web with the creative_production scope, keyed by
// user_id, which IS entitled. Mint that here too (best-effort: if it fails we
// keep the Express token so the other models still work).
if uid := ExtractAccountID(token); uid != "" {
clioBody := "client_id=" + clioClientID + "&scope=" + strings.ReplaceAll(clioScopeValue, ",", "%2C") + "&user_id=" + url.QueryEscape(uid)
if clioPayload, err := doCookieExchange(ctx, sess, cookie, clioBody, "https://firefly.adobe.com"); err == nil {
if clioToken := strings.TrimSpace(stringValue(clioPayload["access_token"])); clioToken != "" {
return &CookieExchangeResult{
AccessToken: clioToken,
ExpiresIn: intValue(clioPayload["expires_in"]),
Raw: clioPayload,
}, nil
}
}
}
return &CookieExchangeResult{
AccessToken: token,
ExpiresIn: intValue(payload["expires_in"]),
Raw: payload,
}, nil
}
func doCookieExchange(ctx context.Context, sess *tlsSession, cookie, body, origin string) (map[string]any, error) {
req, err := http.NewRequest(http.MethodPost, refreshURL, strings.NewReader(body))
if err != nil {
return nil, err
@@ -977,20 +1088,15 @@ func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie s
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"*/*"},
"accept-language": {"zh-CN,zh;q=0.9"},
"accept-language": {"en-US,en;q=0.9"},
"content-type": {"application/x-www-form-urlencoded;charset=UTF-8"},
"cookie": {cookie},
"origin": {"https://new.express.adobe.com"},
"referer": {"https://new.express.adobe.com/"},
"origin": {origin},
"referer": {origin + "/"},
"user-agent": {sess.fp.userAgent},
http.HeaderOrderKey: {
"accept",
"accept-language",
"content-type",
"cookie",
"origin",
"referer",
"user-agent",
"accept", "accept-language", "content-type", "cookie",
"origin", "referer", "user-agent",
},
}
resp, err := sess.client.Do(req)
@@ -1010,15 +1116,7 @@ func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie s
if err := json.Unmarshal(respBody, &payload); err != nil {
return nil, fmt.Errorf("adobe cookie exchange invalid json: %w", err)
}
token := strings.TrimSpace(stringValue(payload["access_token"]))
if token == "" {
return nil, errors.New("adobe cookie exchange missing access_token")
}
return &CookieExchangeResult{
AccessToken: token,
ExpiresIn: intValue(payload["expires_in"]),
Raw: payload,
}, nil
return payload, nil
}
func buildSubmitNonce(token, prompt string) string {
+19 -9
View File
@@ -251,7 +251,7 @@ func cloneMap(in map[string]any) map[string]any {
return out
}
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string) map[string]any {
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs, videoBlobIDs, audioBlobIDs []string) map[string]any {
seedVal := rand.Intn(999999)
engine = defaultString(engine, "sora2")
resolution = defaultString(resolution, "720p")
@@ -307,7 +307,7 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
"prompt": prompt,
"negativePrompt": "",
"duration": durationSeconds,
"generateAudio": true,
"generateAudio": false,
"generationMetadata": map[string]any{
"module": "text2video",
"submodule": "ff-video-generate",
@@ -330,9 +330,9 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
payload["referenceBlobs"] = refs
}
return payload
case "seedance-fast", "seedance-2.0":
case "seedance-2.0-fast", "seedance-2.0":
modelVersion := "seedance_2.0"
if engine == "seedance-fast" {
if engine == "seedance-2.0-fast" {
modelVersion = "seedance_2.0_fast"
}
payload := map[string]any{
@@ -350,19 +350,29 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
"referenceBlobs": []any{},
}
if len(blobIDs) > 0 {
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
refs := make([]any, 0, min(len(blobIDs), 9))
// Seedance keeps module "text2video" even with reference blobs
// (HAR-confirmed) — unlike veo/luma it does not switch to image2video.
if referenceMode == "frame" {
for idx, id := range blobIDs[:min(len(blobIDs), 9)] {
refs := make([]any, 0, min(len(blobIDs), 2))
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
}
payload["referenceBlobs"] = refs
} else {
refs := make([]any, 0, min(len(blobIDs), 9))
for _, id := range blobIDs[:min(len(blobIDs), 9)] {
refs = append(refs, map[string]any{"id": id, "usage": "asset"})
}
refs = append(refs, map[string]any{"id": id, "usage": "style"})
}
payload["referenceBlobs"] = refs
}
}
// Video/audio source refs (seedance)
for _, id := range videoBlobIDs[:min(len(videoBlobIDs), 3)] {
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
}
for _, id := range audioBlobIDs[:min(len(audioBlobIDs), 3)] {
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
}
return payload
case "luma":
payload := map[string]any{
+99 -8
View File
@@ -10,6 +10,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
@@ -19,6 +20,13 @@ import (
// "4BDA81F069FC6DA40A495FAB@AdobeID").
var adobeUserIDPat = regexp.MustCompile(`[A-Fa-f0-9]{20,}@AdobeID`)
// PID pool: one PID per token, reused across requests for the same account.
var (
arpPIDMu sync.Mutex
arpTokenPID = map[string]int{} // token → pid
arpPIDToken = map[int]string{} // pid → token
)
func extractUserIDFromCookie(cookie string) string {
decoded, err := url.QueryUnescape(cookie)
if err != nil {
@@ -95,19 +103,102 @@ func decodeJWTPayload(token string) map[string]any {
return out
}
func buildARPSessionID() string {
// Matches adobe2api's format exactly:
// base64({"sid":"<uuid>","ftr":"<hex16>_<ts_ms>_<pid>_dUAL43-mnts-ants-d4_31ck__tt"})
// Two fields only (no "ark") — mirrors what a real browser session sends.
ftr := randomHex(16) + "_" + strconv.FormatInt(time.Now().UnixMilli(), 10) + "_" + strconv.Itoa(randomInt(1000, 99999)) + "_dUAL43-mnts-ants-d4_31ck__tt"
raw := map[string]any{
// arkHead and arkTail are the site-constant halves of the ark blob, as captured
// from firefly.adobe.com. The leading "<17hex>.<10digits>" pair and the rid
// field sit between them and vary per session, so buildARKBlob randomizes those
// instead of shipping one frozen blob for every account.
const (
arkHead = "|r=ap-southeast-1|meta=3|metabgclr=transparent|metaiconclr=%23757575|guitextcolor=%23000000|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40|sup=1"
arkTail = "|ag=101|cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|surl=https%3A%2F%2Farks-client.adobe.com|smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager"
)
func buildARKBlob() string {
return randomHex(9)[:17] + "." + randomDigits(10) +
arkHead + "|rid=" + strconv.Itoa(randomInt(1, 99)) + arkTail
}
// buildARPSessionID returns the x-arp-session-id value: base64 of
// {"sid","ark","ftr"}, shaped to match live firefly.adobe.com traffic:
//
// ark: <17hex>.<10digits>|…|rid=<n>|…
// ftr: <32hex>_<unix_ms>_<pid>_UDF43-m4_31ck__tt
//
// The pid is allocated per token (allocPID) so it stays stable across an
// account's requests, the way a real browser session would.
//
// The Arkose segment is left empty on purpose. In a real browser that slot
// carries a "<b64>=-<4digits>-v2" payload emitted by Adobe's Arkose bot-manager
// script; a synthesized one has the right shape but fails Adobe's server-side
// validation, and 3p-images/generate-async rejects it with a misleading
// {"error_code":"timeout_error","message":"system under load"}. Sending the
// slot empty reads as "no Arkose payload yet" and is accepted.
func buildARPSessionID(token string) string {
ftr := strings.Join([]string{
randomHex(16), // 32 hex chars, matching the capture
strconv.FormatInt(time.Now().UnixMilli(), 10),
strconv.Itoa(allocPID(token)),
"UDF43-m4",
"31ck",
"",
"tt",
}, "_")
b, _ := json.Marshal(map[string]any{
"sid": uuid.NewString(),
"ark": buildARKBlob(),
"ftr": ftr,
}
b, _ := json.Marshal(raw)
})
return base64.StdEncoding.EncodeToString(b)
}
// randomDigits returns n decimal digits with a non-zero leading digit, so the
// rendered value keeps a constant width.
func randomDigits(n int) string {
if n <= 0 {
return ""
}
var b strings.Builder
b.Grow(n)
b.WriteByte(byte('1' + randomInt(0, 8)))
for i := 1; i < n; i++ {
b.WriteByte(byte('0' + randomInt(0, 9)))
}
return b.String()
}
func randomBase64(n int) string {
buf := make([]byte, n)
rand.Read(buf)
return base64.RawURLEncoding.EncodeToString(buf)
}
func allocPID(token string) int {
arpPIDMu.Lock()
defer arpPIDMu.Unlock()
if pid, ok := arpTokenPID[token]; ok {
return pid
}
for {
pid := randomInt(1000, 99999)
if _, used := arpPIDToken[pid]; !used {
arpPIDToken[pid] = token
arpTokenPID[token] = pid
return pid
}
}
}
// ReleasePID releases the PID bound to token so it can be reused by another
// account.
func ReleasePID(token string) {
arpPIDMu.Lock()
defer arpPIDMu.Unlock()
if pid, ok := arpTokenPID[token]; ok {
delete(arpPIDToken, pid)
delete(arpTokenPID, token)
}
}
func randomHex(n int) string {
if n <= 0 {
return ""
+5
View File
@@ -165,6 +165,11 @@ func (m *MaintenanceService) tick(ctx context.Context) {
// 2. Auto-renew Adobe cookies whose refresh interval has elapsed.
if m.refresh != nil {
if m.settings != nil {
if proxy, err := m.settings.GetValue(ctx, "proxy.url"); err == nil && proxy != "" {
m.refresh.SetProxy(proxy)
}
}
if n, err := m.refresh.RefreshDue(ctx); err != nil {
log.Printf("maintenance: refresh_due: %v", err)
} else if n > 0 {
+40 -9
View File
@@ -26,6 +26,12 @@ func NewRefreshProfileService(profiles *repo.RefreshProfileRepository, tokens *r
}
}
func (s *RefreshProfileService) SetProxy(proxy string) {
if s.adobe != nil {
s.adobe.SetProxy(proxy)
}
}
func (s *RefreshProfileService) List(ctx context.Context) ([]model.RefreshProfile, error) {
return s.profiles.List(ctx)
}
@@ -64,12 +70,11 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
"consecutive_failures": failures,
"next_retry_at": now.Add(time.Duration(secs) * time.Second),
})
// After repeated failures the cookie can no longer mint a token — it's
// genuinely dead (expired/revoked). Lock the pool token (disabled+dead)
// so the UI flags it red. A single failure may be a transient blip, so
// only escalate after a few in a row (mirrors Python RefreshManager).
if failures >= 3 {
_, _ = s.tokens.Update(ctx, profile.Pool, id, map[string]any{
// Mark the token dead when the cookie exchange keeps failing:
// - 5 consecutive failures → cookie is likely expired/revoked
// - ride_AdobeID_acct_actreq (Adobe requires account action) → permanently broken
if failures >= 5 || strings.Contains(msg, "ride_AdobeID_acct_actreq") {
_, _ = s.tokens.Update(ctx, "adobe", id, map[string]any{
"status": "disabled",
"dead": true,
})
@@ -84,12 +89,14 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
"fails": 0,
"updated_at": now,
}
email, exp := parseJWTEmailExpiry(result.AccessToken)
email, _ := parseJWTEmailExpiry(result.AccessToken)
if email != "" {
tokenPatch["account_email"] = email
}
if exp != nil {
tokenPatch["cached_quota_reset_after"] = exp.Format(time.RFC3339)
if profile.IntervalSeconds > 0 {
tokenPatch["cached_quota_reset_after"] = now.Add(time.Duration(profile.IntervalSeconds) * time.Second).Format(time.RFC3339)
} else {
tokenPatch["cached_quota_reset_after"] = now.Add(54000 * time.Second).Format(time.RFC3339)
}
if profileData, profileErr := s.adobe.FetchAccountProfile(ctx, result.AccessToken); profileErr == nil {
if email := strings.TrimSpace(stringValue(profileData["email"])); email != "" {
@@ -99,6 +106,7 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
tokenPatch["account_display_name"] = displayName
}
}
planKnown := false
if quotaData, quotaErr := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); quotaErr == nil {
meta := datatypes.JSONMap{
"cached_quota_at": int(time.Now().Unix()),
@@ -113,9 +121,32 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
meta["cached_quota_total"] = total
}
tokenPatch["meta"] = meta
planCap := strings.ToLower(strings.TrimSpace(stringValue(quotaData["plan"])))
meta["plan"] = planCap
isVIP := planCap != "" && !strings.EqualFold(planCap, "free")
planKnown = planCap != ""
// VIP: use Adobe's reset time; set concurrency 5.
// 母号/子号 身份固定,不随积分动态变化——只有降级普号才刷新身份。
if isVIP {
if resetAfter := strings.TrimSpace(stringValue(quotaData["available_until"])); resetAfter != "" {
tokenPatch["cached_quota_reset_after"] = resetAfter
}
tokenPatch["concurrency"] = 5
} else {
// Free account: concurrency 1, limit image+video
tokenPatch["concurrency"] = 1
tokenPatch["image_limited"] = true
tokenPatch["video_limited"] = true
meta["is_sub_account"] = false
}
}
// 探测不到会员身份(credits 接口失败或没返回 plan)的号既不算普号也不算会员号,
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号。
if !planKnown {
tokenPatch["status"] = "disabled"
tokenPatch["dead"] = true
}
if _, err := s.tokens.Update(ctx, "adobe", id, tokenPatch); err != nil {
return err
+63 -5
View File
@@ -709,17 +709,44 @@ func (s *TokenService) checkPendingAdobe(tokenID, cookie string) {
_, _ = s.tokens.Update(ctx, "adobe", tokenID, seed)
quotaMeta := map[string]any{}
planKnown := false
if cb, e := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); e == nil {
if ra := strings.TrimSpace(stringValue(cb["available_until"])); ra != "" {
_, _ = s.tokens.Update(ctx, "adobe", tokenID, map[string]any{"cached_quota_reset_after": ra})
}
quotaMeta["cached_quota_at"] = int(time.Now().Unix())
planCap := strings.ToLower(strings.TrimSpace(stringValue(cb["plan"])))
quotaMeta["plan"] = planCap
isVIP := planCap != "" && planCap != "free"
planKnown = planCap != ""
if rem, ok := cb["remaining"].(int); ok {
quotaMeta["cached_quota_remaining"] = rem
if rem <= 4000 {
if isVIP && rem > 0 && rem <= 4000 {
quotaMeta["is_sub_account"] = true
}
}
// Set concurrency + limits based on plan
concurrency := 1
imageLimit := true
videoLimit := true
if isVIP {
concurrency = 5
imageLimit = false
videoLimit = false
if ra := strings.TrimSpace(stringValue(cb["available_until"])); ra != "" {
_, _ = s.tokens.Update(ctx, "adobe", tokenID, map[string]any{"cached_quota_reset_after": ra})
}
}
patch := map[string]any{
"concurrency": concurrency,
"image_limited": imageLimit,
"video_limited": videoLimit,
"meta": quotaMeta,
}
if planKnown {
patch["status"] = "active"
patch["dead"] = false
}
_, _ = s.tokens.Update(ctx, "adobe", tokenID, patch)
}
if prof, e := s.adobe.FetchAccountProfile(ctx, result.AccessToken); e == nil {
p := map[string]any{}
@@ -734,6 +761,13 @@ func (s *TokenService) checkPendingAdobe(tokenID, cookie string) {
}
}
// 探测不到会员身份(credits 接口失败或没返回 plan)的号既不算普号也不算会员号,
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号,等重新探测到 plan 再启用。
if !planKnown {
log.Printf("token import: adobe %s plan unknown, marking dead", tokenID)
s.finishPending(ctx, "adobe", tokenID, "disabled", true, quotaMeta)
return
}
s.finishPending(ctx, "adobe", tokenID, "active", false, quotaMeta)
_, _ = s.refresh.Update(ctx, tokenID, map[string]any{
"last_attempt_at": time.Now(),
@@ -1274,9 +1308,21 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
patch := map[string]any{}
meta := cloneJSONMap(item.Meta)
meta["cached_quota_at"] = int(time.Now().Unix())
plan := strings.ToLower(strings.TrimSpace(stringValue(data["plan"])))
if plan != "" {
meta["plan"] = plan
}
if remaining, ok := data["remaining"].(int); ok {
meta["cached_quota_remaining"] = remaining
meta["is_sub_account"] = remaining <= 4000
// is_sub_account 仅在导入时写入,刷新配额时不覆盖
if _, hasFlag := meta["is_sub_account"]; !hasFlag && plan != "free" {
meta["is_sub_account"] = remaining > 0 && remaining <= 4000
}
}
// 子号必然是会员号:plan 查回 free 说明这个号没有会员,不能再挂子号标记
// (否则同一行会同时显示 子号 和 普号)。
if plan == "free" {
meta["is_sub_account"] = false
}
if used, ok := data["used"].(int); ok {
meta["cached_quota_used"] = used
@@ -1285,6 +1331,12 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
meta["cached_quota_total"] = total
}
patch["meta"] = meta
// 探测不到会员身份(新旧 plan 都为空)的号既不算普号也不算会员号,
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号。
if strings.TrimSpace(stringValue(meta["plan"])) == "" {
patch["status"] = "disabled"
patch["dead"] = true
}
if resetAfter := strings.TrimSpace(stringValue(data["available_until"])); resetAfter != "" {
patch["cached_quota_reset_after"] = resetAfter
item.CachedQuotaResetAfter = resetAfter
@@ -1294,6 +1346,7 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
item = updated
}
}
subAccount, _ := jsonMapBool(meta, "is_sub_account")
return map[string]any{
"supported": true,
"remaining": data["remaining"],
@@ -1304,6 +1357,9 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
"unchanged": false,
"unknown": boolValueWithDefault(data["unknown"], false),
"error": data["error"],
// 会员身份跟着额度一起返回,前端查一次额度就能刷新 子号/母号/普号 徽章。
"plan": emptyToNil(strings.ToLower(strings.TrimSpace(stringValue(meta["plan"])))),
"sub_account": subAccount,
}, nil
}
if poolToType(item.Pool) == "krea" && s.krea != nil {
@@ -1648,6 +1704,7 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
quotaAt, _ := jsonMapInt(item.Meta, "cached_quota_at")
pending, _ := jsonMapBool(item.Meta, "pending_check")
subAccount, _ := jsonMapBool(item.Meta, "is_sub_account")
plan, _ := item.Meta["plan"]
// OpenAI email lives in the token's JWT (nested profile claim). Decode it at
// render time like the Python reference (_account_row) so accounts imported
// before the email was persisted still show a name; fall back to the cached
@@ -1686,6 +1743,7 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
"video_limited": item.VideoLimited,
"pending": pending,
"sub_account": subAccount,
"plan": emptyToNil(strings.ToLower(strings.TrimSpace(stringValue(plan)))),
"quota_supported": hasQuota,
"needs_reset_fetch": typeLabel == "adobe" && item.Status == "active" && strings.TrimSpace(item.CachedQuotaResetAfter) == "",
"weight": item.Weight,
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"backend/internal/model"
@@ -33,6 +34,9 @@ type UserGenerateRequest struct {
Resolution string
Duration string
ReferenceImages []string
// ReferenceMode overrides the model's default ("frame" or "asset") —
// the 画图台 首尾帧/参考图 toggle for models that support both.
ReferenceMode string
// DeAI applies 去AI特征 post-processing to the generated image (image only)
// and charges the per-tier surcharge on top of the model price.
DeAI bool
@@ -59,6 +63,9 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
switch modelItem.Type {
case "video":
if err := validateReferenceMode(in.ReferenceMode, modelItem, len(in.ReferenceImages)); err != nil {
return nil, err
}
resp, err := s.v1.prepareSessionVideo(ctx, principal, V1VideoRequest{
Model: in.Model,
Prompt: in.Prompt,
@@ -66,6 +73,7 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
AspectRatio: in.Ratio,
Resolution: in.Resolution,
ReferenceImages: in.ReferenceImages,
ReferenceMode: strings.TrimSpace(in.ReferenceMode),
})
if err != nil {
return nil, err
@@ -87,6 +95,27 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
}
}
// validateReferenceMode mirrors the /v1 checks: the override must be "frame"
// or "asset", the model must support references at all, and frame mode carries
// at most 2 images (first+last frame).
func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int) error {
rm = strings.TrimSpace(rm)
if rm == "" {
return nil
}
supported := strings.TrimSpace(modelItem.ReferenceMode)
if supported == "" || supported == "none" {
return fmt.Errorf("%w: reference_mode not supported for this model", ErrUnsupportedParams)
}
if rm != "frame" && rm != "asset" {
return fmt.Errorf("%w: reference_mode must be 'frame' or 'asset'", ErrUnsupportedParams)
}
if rm == "frame" && refCount > 2 {
return fmt.Errorf("%w: frame mode supports at most 2 reference images (first+last frame), got %d", ErrUnsupportedParams, refCount)
}
return nil
}
func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) {
if user == nil || strings.TrimSpace(user.ID) == "" {
return nil, errors.New("未登录或会话已过期")
+294 -32
View File
@@ -219,6 +219,7 @@ type V1VideoRequest struct {
AspectRatio string
Resolution string
ReferenceImages []string
ReferenceMode string // "frame" or "asset", overrides model default
// BaseURL — see V1ImageRequest.BaseURL.
BaseURL string
// AccountID — see V1ImageRequest.AccountID.
@@ -325,6 +326,11 @@ func (s *V1Service) refreshAdobeToken(ctx context.Context, tokenID string) (mode
if s.refresh == nil {
return model.TokenAccount{}, false
}
if s.settings != nil {
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil && proxy != "" {
s.refresh.SetProxy(proxy)
}
}
if err := s.refresh.RefreshNow(ctx, tokenID); err != nil {
return model.TokenAccount{}, false
}
@@ -883,6 +889,24 @@ func (s *V1Service) StartVideoJob(ctx context.Context, principal *APIPrincipal,
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", err.Error())
return nil, err
}
// Validate reference_mode against model capabilities and reference count.
if rm := strings.TrimSpace(in.ReferenceMode); rm != "" {
supported := strings.TrimSpace(modelItem.ReferenceMode)
if supported == "none" || supported == "" {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", "reference_mode not supported for this model")
return nil, errors.New("reference_mode not supported for this model")
}
if rm != "frame" && rm != "asset" {
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", "reference_mode must be 'frame' or 'asset'")
return nil, errors.New("reference_mode must be 'frame' or 'asset'")
}
if rm == "frame" && len(in.ReferenceImages) > 2 {
return nil, fmt.Errorf("frame mode supports at most 2 reference images (first+last frame), got %d", len(in.ReferenceImages))
}
if strings.TrimSpace(in.ReferenceMode) == modelItem.ReferenceMode {
in.ReferenceMode = "" // same as default, don't override
}
}
// Source "v1": no output file is allocated — the result is the upstream URL,
// stored on the event when the render completes.
eventID, err := s.logPendingEvent(ctx, "video", modelItem, principal, in.Prompt, aspectRatio, resolution, duration, len(in.ReferenceImages), price, "", "v1", nil, false)
@@ -1251,6 +1275,26 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
if !modelItem.Enabled || modelItem.Type != "video" {
return nil, "", "", "", 0, ErrUnknownModel
}
// Validate duration against model's supported range (from Durations JSON array).
if secs := parseDurationSeconds(duration); secs > 0 {
if durList := repo.JSONStrings(modelItem.Durations); len(durList) > 0 {
minSecs, maxSecs := 9999, 0
for _, d := range durList {
n := parseDurationSeconds(d)
if n > 0 {
if n < minSecs {
minSecs = n
}
if n > maxSecs {
maxSecs = n
}
}
}
if secs < minSecs || secs > maxSecs {
return nil, "", "", "", 0, fmt.Errorf("duration %ds out of range [%d-%d] for model %s", secs, minSecs, maxSecs, modelItem.EffectiveName())
}
}
}
// Fail fast before charging — effective provider (custom upstream by id, else native).
if eff := s.effectiveProvider(ctx, modelItem); eff == "custom" {
// custom serves this id (effectiveProvider guaranteed it) — precheck ok
@@ -1296,6 +1340,11 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
if err != nil {
return nil, "", "", "", 0, err
}
// 规范化 duration 字段:前端 per_second 计费模式可能发来 "per_second" 字符串,
// 统一转为 "Xs" 格式(如 "4s")存库,避免日志显示原始键名。
if n := parseDurationSeconds(duration); n > 0 {
duration = fmt.Sprintf("%ds", n)
}
return modelItem, resolution, aspectRatio, duration, price, nil
}
@@ -1438,7 +1487,6 @@ func (s *V1Service) finishUnimplementedEvent(ctx context.Context, eventID string
return s.events.UpdateStatus(ctx, eventID, "failed", "generation executor not implemented yet", 0)
}
// grokConcurrencyPerAccount is how many simultaneous generations one grok account
// may run (grok tolerates 10, unlike the 1-per-account default elsewhere).
const grokConcurrencyPerAccount = 10
@@ -1447,7 +1495,7 @@ const grokConcurrencyPerAccount = 10
// policy may burn per request before giving up, so an upstream-wide blip
// ("system under load") can't fan a single request out across the whole pool.
// After this many accounts fail this way, the request fails.
const maxTempDeadAccounts = 3
const maxTempDeadAccounts = 10
// runPoolWithFailover drives a generation across a round-robin-ordered account
// list with per-error-class behavior, so a bad request never burns the whole
@@ -1479,8 +1527,8 @@ func (s *V1Service) runPoolWithFailover(ctx context.Context, eventID, pool strin
busy := 0
tempDeadCount := 0
for _, token := range active {
// 1 concurrent job per account: skip any account already generating.
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
// Per-account concurrency gate (defaults to 1 for built-in pools).
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
busy++
continue
}
@@ -1533,6 +1581,15 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
_ = s.tokens.TouchLastUsed(ctx, token.ID)
authRefreshed := false
if strings.TrimSpace(token.Value) == "" && refreshOnAuth != nil {
if refreshed, ok := refreshOnAuth(token.ID); ok {
token = refreshed
authRefreshed = true
} else {
s.markTokenDead(ctx, pool, token, kind)
return nil, ErrProviderExecution, true, true
}
}
for {
data, err := attempt(token)
if err == nil {
@@ -1549,6 +1606,13 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token
return nil, err, true, false
}
if isAuth {
// A 403 user_not_entitled means the account has no Firefly entitlement
// — refreshing the access token can't grant one, so kill it now instead
// of leaving it in rotation to burn every future request.
if errors.Is(err, adobe.ErrNotEntitled) {
s.markTokenDead(ctx, pool, token, kind)
return nil, err, true, true
}
// Refresh from cookie and retry ONCE; otherwise the credential is dead.
if refreshOnAuth != nil && !authRefreshed {
if refreshed, ok := refreshOnAuth(token.ID); ok {
@@ -1612,15 +1676,27 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
for _, item := range items {
// Adobe accounts are credit-based (积分号) — no per-kind quota locks.
// Only skip accounts that are dead or disabled.
if item.Status == "active" && !item.Dead && strings.TrimSpace(item.Value) != "" {
active = append(active, item)
if item.Status != "active" || item.Dead {
continue
}
// plan 未探测到的号既不算普号也不算会员号,置死号、不参与调度
if planUnknown(item.Meta) {
s.markPlanUnknownDead(ctx, "adobe", item.ID)
continue
}
// 普号(free)只能调度 free_allowed 的模型(香蕉2 仅 1K
if !freeAccountsAllowed(modelItem, resolution) && isFreeAccount(item.Meta) {
continue
}
active = append(active, item)
}
active = pinTestAccount(items, active, in.AccountID)
if len(active) == 0 {
return nil, "", ErrNoProviderAccount
}
s.rotateRoundRobin("adobe", active)
// 非 seedance 图片生成:普号 → 子号 → 母号
active = prioritizeSubAccounts(active)
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
if err != nil {
@@ -1637,6 +1713,13 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
for _, ref := range refs {
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", "")
if upErr != nil {
if errors.Is(upErr, adobe.ErrRateLimited) {
recoverAt := time.Now().Add(4 * time.Hour)
s.tokens.Update(ctx, "adobe", token.ID, map[string]any{
"status": "quota",
"quota_recover_at": &recoverAt,
})
}
return nil, upErr
}
blobIDs = append(blobIDs, id)
@@ -1668,10 +1751,21 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
}
var active []model.TokenAccount
for _, item := range items {
if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
if item.Status != "active" || item.Dead {
continue
}
if isSeedanceModel(modelItem.ID) && isSubAccount(item.Meta) {
// plan 未探测到的号既不算普号也不算会员号,置死号、不参与调度
if planUnknown(item.Meta) {
s.markPlanUnknownDead(ctx, "adobe", item.ID)
continue
}
// Seedance 模型只允许 VIP 母号:必须正向识别(plan 非 free、非子号、
// 积分 >4000),plan/额度未探测的账号一律不参与调度
if isSeedanceModel(modelItem.ID) && !isVipMotherAccount(item.Meta) {
continue
}
// 普号(free)只能调度 free_allowed 的模型
if !freeAccountsAllowed(modelItem, resolution) && isFreeAccount(item.Meta) {
continue
}
active = append(active, item)
@@ -1681,6 +1775,11 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
return nil, "", ErrNoProviderAccount
}
s.rotateRoundRobin("adobe", active)
// 非 seedance 视频生成:普号 → 子号 → 母号
// (seedance 已在上面过滤掉子号,此处无需额外处理)
if !isSeedanceModel(modelItem.ID) {
active = prioritizeSubAccounts(active)
}
refLimit := modelItem.MaxReferenceImages
if refLimit <= 0 {
@@ -1690,9 +1789,24 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
if err != nil {
return nil, "", err
}
// Classify refs for seedance: images (usage:style), videos, audio (usage:source).
var imgRefs, vidRefs, audRefs [][]byte
for _, r := range refs {
switch detectMediaType(r) {
case "video":
vidRefs = append(vidRefs, r)
case "audio":
audRefs = append(audRefs, r)
default:
imgRefs = append(imgRefs, r)
}
}
engine, upstreamModel := resolveAdobeVideoEngine(modelItem.ID)
referenceMode := defaultString(strings.TrimSpace(modelItem.ReferenceMode), "frame")
if rm := strings.TrimSpace(in.ReferenceMode); rm != "" {
referenceMode = rm
}
// Round-robin order; fail over to the next account on auth/quota; temporary
// upstream errors fail over too without penalizing the account (tempFailover,
@@ -1701,14 +1815,37 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
var videoURL string
data, err := s.runPoolWithFailover(ctx, eventID, "adobe", active, "video", func(token model.TokenAccount) ([]byte, error) {
var blobIDs []string
for _, ref := range refs {
for _, ref := range imgRefs {
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", engine)
if upErr != nil {
if errors.Is(upErr, adobe.ErrRateLimited) {
recoverAt := time.Now().Add(4 * time.Hour)
s.tokens.Update(ctx, "adobe", token.ID, map[string]any{
"status": "quota",
"quota_recover_at": &recoverAt,
})
}
return nil, upErr
}
blobIDs = append(blobIDs, id)
}
bytes, meta, genErr := s.adobe.GenerateVideo(ctx, token.Value, engine, in.Prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs, downloadResult)
var videoBlobIDs []string
for _, ref := range vidRefs {
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "video/mp4", engine)
if upErr != nil {
return nil, upErr
}
videoBlobIDs = append(videoBlobIDs, id)
}
var audioBlobIDs []string
for _, ref := range audRefs {
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "audio/mp3", engine)
if upErr != nil {
return nil, upErr
}
audioBlobIDs = append(audioBlobIDs, id)
}
bytes, meta, genErr := s.adobe.GenerateVideo(ctx, token.Value, engine, in.Prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs, videoBlobIDs, audioBlobIDs, downloadResult)
if genErr == nil {
videoURL = strings.TrimSpace(stringValue(meta["video_url"]))
}
@@ -1771,8 +1908,8 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
var videoURL string
busy := 0
for _, token := range active {
// 1 concurrent job per account: skip any account already generating.
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
// Per-account concurrency gate (defaults to 1 for built-in pools).
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
busy++
continue
}
@@ -1869,9 +2006,21 @@ func (s *V1Service) customActive(ctx context.Context, modelID string) ([]model.T
// accountConcurrency is the per-account simultaneous-job cap. Custom accounts use
// their configured Concurrency (default 1); built-in pools use the system value.
func accountConcurrency(item model.TokenAccount) int {
if item.Pool == "adobe" {
if isFreeAccount(item.Meta) {
return 1 // FREE 普号 / 降级号限制为 1 并发
}
if item.Concurrency > 0 {
return item.Concurrency
}
return 5 // VIP 会员号默认 5 并发
}
if item.Concurrency > 0 {
return item.Concurrency
}
if item.Pool == "grok" {
return grokConcurrencyPerAccount // 10
}
return 1
}
@@ -2159,9 +2308,8 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
var videoURL string
busy := 0
for _, token := range active {
// grok allows 10 concurrent jobs per account (unlike the 1-per-account
// default of the other pools).
if !s.acctAcquire(ctx, token.ID, eventID, grokConcurrencyPerAccount) {
// Per-account concurrency gate (defaults to 1 for built-in pools).
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
busy++
continue
}
@@ -2267,8 +2415,8 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
var lastErr error
busy := 0
for _, token := range active {
// 1 concurrent job per account: skip any account already generating.
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
// Per-account concurrency gate (defaults to 1 for built-in pools).
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
busy++
continue
}
@@ -2860,6 +3008,40 @@ func decodeReferenceImages(inputs []string, limit int) ([][]byte, error) {
return out, nil
}
// detectMediaType inspects the first bytes of a decoded reference to classify it
// as "video", "audio", or "image". Used to route refs to the correct upload MIME
// and the correct referenceBlobs usage for seedance.
func detectMediaType(data []byte) string {
n := len(data)
if n < 8 {
return "image"
}
// MP4 / ISOBMFF
if n >= 12 && string(data[4:8]) == "ftyp" {
return "video"
}
// WebM
if n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3 {
return "video"
}
// MP3: ID3 header or sync word 0xFFFx
if n >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
return "audio"
}
if n >= 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0 {
return "audio"
}
// WAV
if n >= 4 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 {
return "audio"
}
// OGG
if n >= 4 && data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53 {
return "audio"
}
return "image"
}
func parseImageSize(size, aspectRatio, resolution string) (string, string) {
ar := strings.TrimSpace(strings.ReplaceAll(aspectRatio, "x", ":"))
rs := strings.TrimSpace(resolution)
@@ -3162,8 +3344,8 @@ func resolveAdobeVideoEngine(modelID string) (string, string) {
return "veo31-fast", ""
case "gemini-veo3.1":
return "veo31-standard", ""
case "seedance-fast":
return "seedance-fast", ""
case "seedance-2.0-fast":
return "seedance-2.0-fast", ""
case "seedance-2.0":
return "seedance-2.0", ""
case "firefly-ray":
@@ -3216,16 +3398,8 @@ func (s *V1Service) markTokenFailure(ctx context.Context, pool string, token mod
}
switch {
case isQuota:
// Adobe accounts are credit-based (积分号) — quota exhaustion is
// non-locking: track the failure for rotation but don't limit or
// sink the account. Other pools go straight to "quota" as before.
if pool == "adobe" {
// No-op: just track fails (already patched above), leave
// image_limited/video_limited/status untouched.
} else {
patch["status"] = "quota"
}
if pool != "adobe" && strings.TrimSpace(token.CachedQuotaResetAfter) == "" {
if strings.TrimSpace(token.CachedQuotaResetAfter) == "" {
recoverAt := time.Unix((time.Now().Unix()/86400+1)*86400, 0).UTC()
patch["quota_recover_at"] = &recoverAt
}
@@ -3329,18 +3503,106 @@ func (s *V1Service) rotateRoundRobin(pool string, items []model.TokenAccount) {
}
}
// freeOnly1KModelID is the one free-allowed model 普号 may only serve at 1K
// (香蕉2 的 2K/4K 需要会员号) — see freeAccountsAllowed.
const freeOnly1KModelID = "nano-banana-2"
func isSeedanceModel(modelID string) bool {
return modelID == "seedance-fast" || modelID == "seedance-2.0"
return modelID == "seedance-2.0-fast" || modelID == "seedance-2.0"
}
func isSubAccount(meta map[string]interface{}) bool {
// freeAccountsAllowed reports whether 普号(free) may serve this request: the model
// must be marked free_allowed. 香蕉2 另外只允许 1K 档,它的 2K/4K 只走会员号。
func freeAccountsAllowed(modelItem *model.ModelConfig, resolution string) bool {
if modelItem == nil || !modelItem.FreeAllowed {
return false
}
if modelItem.ID == freeOnly1KModelID {
return strings.EqualFold(strings.TrimSpace(resolution), "1K")
}
return true
}
func isFreeAccount(meta map[string]interface{}) bool {
if meta == nil {
return false
}
plan := strings.ToLower(strings.TrimSpace(stringValue(meta["plan"])))
return plan == "free"
}
// planUnknown 报告账号的会员身份还没探测出来(meta.plan 缺失或为空)。这类号
// 既不能当普号也不能当会员号用:当会员号派出去会在需要会员的模型上撞 403
// user_not_entitled。
func planUnknown(meta map[string]interface{}) bool {
if meta == nil {
return true
}
return strings.TrimSpace(stringValue(meta["plan"])) == ""
}
// markPlanUnknownDead 把选号时遇到的 plan 未探测账号置为死号,等重新探测到
// plan 后再由额度刷新恢复。
func (s *V1Service) markPlanUnknownDead(ctx context.Context, pool, id string) {
s.tokens.Update(ctx, pool, id, map[string]any{"status": "disabled", "dead": true})
}
// prioritizeSubAccounts 对非 Seedance 模型按 普号 → 子号 → 母号 的顺序排序:
// 先消耗普号,普号不可用再用低积分子号,最后才动 vip 母号。
func prioritizeSubAccounts(active []model.TokenAccount) []model.TokenAccount {
var frees, subs, mothers []model.TokenAccount
for _, a := range active {
switch {
case isFreeAccount(a.Meta):
frees = append(frees, a)
case isLowCredits(a.Meta):
subs = append(subs, a)
default:
mothers = append(mothers, a)
}
}
return append(append(frees, subs...), mothers...)
}
// isVipMotherAccount 正向识别 VIP 母号:plan 已知且非 free,且 is_sub_account
// 显式为 false。只看身份不看积分余额(低积分母号也可用);plan 未探测或
// is_sub_account 缺失的账号返回 false,等刷新补齐后才可被 Seedance 调度。
func isVipMotherAccount(meta map[string]interface{}) bool {
if meta == nil {
return false
}
plan := strings.ToLower(strings.TrimSpace(stringValue(meta["plan"])))
if plan == "" || plan == "free" {
return false
}
v, ok := meta["is_sub_account"]
if !ok {
return false
}
b, _ := v.(bool)
return b
switch val := v.(type) {
case bool:
return !val
case float64:
return val == 0
}
return false
}
func isLowCredits(meta map[string]interface{}) bool {
if meta == nil {
return false
}
if v, ok := meta["is_sub_account"]; ok {
switch val := v.(type) {
case bool:
return val
case float64:
return val != 0
}
}
// 兼容存量账号:is_sub_account 字段不存在时,用积分余额判断(>0 且 ≤4000 视为子号)
if rem, ok := jsonMapInt(meta, "cached_quota_remaining"); ok {
return rem > 0 && rem <= 4000
}
return false
}
+12 -12
View File
@@ -1,17 +1,17 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" fill="none">
<defs>
<linearGradient id="vbg" x1="6" y1="4" x2="42" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#a855f7" />
<stop offset="0.55" stop-color="#7c3aed" />
<stop offset="1" stop-color="#ec4899" />
<linearGradient id="ring" x1="6" y1="6" x2="42" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#e879f9"/><stop offset=".5" stop-color="#8b5cf6"/><stop offset="1" stop-color="#38bdf8"/>
</linearGradient>
<linearGradient id="vgl" x1="24" y1="2" x2="24" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffffff" stop-opacity="0.28" />
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
<linearGradient id="v" x1="14" y1="14" x2="34" y2="34" gradientUnits="userSpaceOnUse">
<stop stop-color="#f0abfc"/><stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="44" height="44" rx="13" fill="url(#vbg)" />
<rect x="2" y="2" width="44" height="22" rx="13" fill="url(#vgl)" />
<path d="M12 15 H19 L24 26 L24 37 Z" fill="#ffffff" fill-opacity="0.95" />
<path d="M36 15 H29 L24 26 L24 37 Z" fill="#ffffff" fill-opacity="0.72" />
<g transform="translate(24 24)">
<circle r="18.5" fill="none" stroke="url(#ring)" stroke-width="3.6" stroke-linecap="round"
stroke-dasharray="86 30" transform="rotate(-58)"/>
<path d="M-8 -7.5 L0 9 L8 -7.5" fill="none" stroke="url(#v)" stroke-width="5.2"
stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="13" cy="-13" r="3.6" fill="#38bdf8"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 898 B

After

Width:  |  Height:  |  Size: 934 B

+9 -2
View File
@@ -6,8 +6,15 @@ import Icon from './Icon.vue'
const props = defineProps({ account: { type: Object, required: true } })
const emit = defineEmits(['close', 'saved'])
// concurrency is only adjustable for custom upstreams — others are system-fixed.
// concurrency is only adjustable for custom upstreams — others are system-fixed
// (adobe is set from the plan on every quota refresh: 会员号 5 / 普号 1).
const canEditConcurrency = props.account.type === 'custom'
const fixedConcurrency =
props.account.type === 'adobe'
? (props.account.plan === 'free' ? 1 : Number(props.account.concurrency) || 5)
: props.account.type === 'grok'
? 10
: 1
const weight = ref(Number(props.account.weight) || 0)
const concurrency = ref(Number(props.account.concurrency) || 1)
@@ -61,7 +68,7 @@ async function submit() {
<div>
<label class="lbl">并发数 <span class="text-white/35">{{ canEditConcurrency ? '(单账号)' : '(系统固定,不可调整)' }}</span></label>
<input v-if="canEditConcurrency" v-model.number="concurrency" type="number" min="1" class="field" placeholder="1" />
<input v-else :value="account.type === 'grok' ? 10 : 1" disabled class="field" />
<input v-else :value="fixedConcurrency" disabled class="field" />
</div>
<p v-if="status" class="text-xs" :class="isError ? 'text-rose-400' : 'text-emerald-400'">{{ status }}</p>
<div class="flex justify-end gap-2 pt-2">
+3
View File
@@ -25,6 +25,9 @@ const PATHS = {
receipt: '<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 17.5v-11"/>',
book: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
music: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
play: '<path d="m6 3 14 9-14 9V3Z"/>',
pause: '<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
}
defineProps({ name: { type: String, required: true } })
+1 -1
View File
@@ -113,7 +113,7 @@ async function doSmartImport() {
全粘进来即可无需任何前缀
</p>
<p class="text-xs text-rose-500 mb-3 leading-relaxed bg-rose-50 rounded px-3 py-2 border border-rose-200">
📢 需要购买 Adobe 号请联系 QQ432539
📢 需要购买 Adobe 号请联系 QQ28998306
</p>
<textarea v-model="input" rows="10"
class="field font-mono text-xs resize-none"
+1 -1
View File
@@ -176,7 +176,7 @@ async function submit() {
<!-- header -->
<div class="flex flex-col items-center text-center mb-6">
<div class="logo-halo mb-3"><Logo :size="44" class="rounded-[13px]" /></div>
<div class="logo-halo mb-3"><Logo :size="44" /></div>
<transition name="swap" mode="out-in">
<div :key="mode">
<h2 class="text-lg font-semibold tracking-tight text-[color:var(--fg)]">{{ heading.title }}</h2>
+21 -19
View File
@@ -1,6 +1,7 @@
<script setup>
// Vivid mark: a violet→fuchsia rounded badge with a faceted, folded "V".
// Self-contained SVG so it renders identically on dark and light shells.
// Vivid mark: an open spectrum ring (lens/aperture) around a folded "V", with a
// shutter dot closing the gap — image + video in one glyph. Transparent
// background so it reads on both the dark and light shells.
// Each instance gets unique gradient ids (useId) so multiple logos on one
// page don't share/clobber defs.
import { useId } from 'vue'
@@ -8,32 +9,33 @@ import { useId } from 'vue'
defineProps({ size: { type: [Number, String], default: 40 } })
const uid = useId()
const gBg = `vbg-${uid}`
const gGloss = `vgl-${uid}`
const gRing = `vring-${uid}`
const gV = `vv-${uid}`
</script>
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none"
xmlns="http://www.w3.org/2000/svg" class="shrink-0">
<defs>
<linearGradient :id="gBg" x1="6" y1="4" x2="42" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#a855f7" />
<stop offset="0.55" stop-color="#7c3aed" />
<stop offset="1" stop-color="#ec4899" />
<linearGradient :id="gRing" x1="6" y1="6" x2="42" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#e879f9" />
<stop offset="0.5" stop-color="#8b5cf6" />
<stop offset="1" stop-color="#38bdf8" />
</linearGradient>
<linearGradient :id="gGloss" x1="24" y1="2" x2="24" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffffff" stop-opacity="0.28" />
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
<linearGradient :id="gV" x1="14" y1="14" x2="34" y2="34" gradientUnits="userSpaceOnUse">
<stop stop-color="#f0abfc" />
<stop offset="1" stop-color="#8b5cf6" />
</linearGradient>
</defs>
<!-- badge -->
<rect x="2" y="2" width="44" height="44" rx="13" :fill="`url(#${gBg})`" />
<!-- top gloss for depth -->
<rect x="2" y="2" width="44" height="22" rx="13" :fill="`url(#${gGloss})`" />
<!-- faceted V: left arm brighter, right arm dimmer -> folded look -->
<path d="M12 15 H19 L24 26 L24 37 Z" fill="#ffffff" fill-opacity="0.95" />
<path d="M36 15 H29 L24 26 L24 37 Z" fill="#ffffff" fill-opacity="0.72" />
<g transform="translate(24 24)">
<!-- open ring: dash gap leaves room for the shutter dot -->
<circle r="18.5" fill="none" :stroke="`url(#${gRing})`" stroke-width="3.2"
stroke-linecap="round" stroke-dasharray="86 30" transform="rotate(-58)" />
<!-- folded V -->
<path d="M-8 -7.5 L0 9 L8 -7.5" fill="none" :stroke="`url(#${gV})`" stroke-width="4.8"
stroke-linecap="round" stroke-linejoin="round" />
<circle cx="13" cy="-13" r="3.4" fill="#38bdf8" />
</g>
</svg>
</template>
+102
View File
@@ -0,0 +1,102 @@
<script setup>
// Vivi — the platform's original chibi mascot. Pure inline SVG (no raster
// asset, no external dependency), so it scales crisply and inherits the
// pink/violet/sky brand palette. `size` sets the square px box; the halo of
// twin-tails + a camera-lens hair ring ties her to the B-ring logo.
import { computed } from 'vue'
const props = defineProps({
size: { type: [Number, String], default: 160 },
float: { type: Boolean, default: true },
uid: { type: String, default: () => 'm' + Math.random().toString(36).slice(2, 8) },
})
const px = computed(() => (typeof props.size === 'number' ? props.size + 'px' : props.size))
const g = (k) => `${props.uid}-${k}`
</script>
<template>
<svg :width="px" :height="px" viewBox="0 0 200 200" fill="none"
:class="['mascot', { 'mascot-float': float }]" aria-hidden="true">
<defs>
<linearGradient :id="g('hair')" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f0abfc" />
<stop offset="0.55" stop-color="#c084fc" />
<stop offset="1" stop-color="#818cf8" />
</linearGradient>
<linearGradient :id="g('eye')" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#7dd3fc" />
<stop offset="1" stop-color="#a855f7" />
</linearGradient>
<linearGradient :id="g('ring')" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f472b6" />
<stop offset="0.5" stop-color="#a78bfa" />
<stop offset="1" stop-color="#38bdf8" />
</linearGradient>
<radialGradient :id="g('glow')" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#e879f9" stop-opacity="0.55" />
<stop offset="1" stop-color="#e879f9" stop-opacity="0" />
</radialGradient>
</defs>
<!-- soft aura -->
<circle cx="100" cy="104" r="86" :fill="`url(#${g('glow')})`" />
<!-- back twin-tail -->
<path d="M56 78 C22 84 20 140 44 168 C58 150 60 120 74 104 Z" :fill="`url(#${g('hair')})`" opacity="0.92" />
<path d="M144 78 C178 84 180 140 156 168 C142 150 140 120 126 104 Z" :fill="`url(#${g('hair')})`" opacity="0.92" />
<!-- hair back mass -->
<path d="M100 34 C60 34 44 66 46 100 C47 116 56 128 62 132 L138 132 C144 128 153 116 154 100 C156 66 140 34 100 34 Z"
:fill="`url(#${g('hair')})`" />
<!-- face -->
<path d="M64 92 C64 66 80 52 100 52 C120 52 136 66 136 92 C136 118 120 134 100 134 C80 134 64 118 64 92 Z"
fill="#fff5fb" />
<!-- fringe -->
<path d="M62 92 C60 66 78 50 100 50 C122 50 140 66 138 92 C132 78 120 74 118 90 C112 74 100 72 100 72 C100 72 88 74 82 90 C80 74 68 78 62 92 Z"
:fill="`url(#${g('hair')})`" />
<!-- ears blush -->
<circle cx="76" cy="106" r="7" fill="#fbb6ce" opacity="0.75" />
<circle cx="124" cy="106" r="7" fill="#fbb6ce" opacity="0.75" />
<!-- eyes -->
<g>
<ellipse cx="83" cy="98" rx="9" ry="12" :fill="`url(#${g('eye')})`" />
<ellipse cx="117" cy="98" rx="9" ry="12" :fill="`url(#${g('eye')})`" />
<circle cx="85.5" cy="93.5" r="3.2" fill="#fff" />
<circle cx="119.5" cy="93.5" r="3.2" fill="#fff" />
<circle cx="81" cy="101" r="1.6" fill="#fff" opacity="0.85" />
<circle cx="115" cy="101" r="1.6" fill="#fff" opacity="0.85" />
</g>
<!-- brows / smile -->
<path d="M74 84 q9 -5 18 -1" stroke="#c084fc" stroke-width="2.4" stroke-linecap="round" />
<path d="M108 83 q9 -4 18 1" stroke="#c084fc" stroke-width="2.4" stroke-linecap="round" />
<path d="M92 114 q8 7 16 0" stroke="#f472b6" stroke-width="2.6" stroke-linecap="round" fill="none" />
<!-- camera-lens hair ring (echoes the logo) -->
<g transform="translate(140 58)">
<circle r="15" fill="none" :stroke="`url(#${g('ring')})`" stroke-width="4"
stroke-linecap="round" stroke-dasharray="70 24" transform="rotate(-55)" />
<circle r="5.5" fill="#38bdf8" />
<circle r="2.2" fill="#fff" opacity="0.9" />
</g>
<!-- sparkles -->
<g fill="#fef08a">
<path d="M46 60 l2.2 5 5 2.2 -5 2.2 -2.2 5 -2.2 -5 -5 -2.2 5 -2.2 Z" />
<path d="M158 128 l1.6 3.6 3.6 1.6 -3.6 1.6 -1.6 3.6 -1.6 -3.6 -3.6 -1.6 3.6 -1.6 Z" />
</g>
<circle cx="52" cy="150" r="2.4" fill="#7dd3fc" />
<circle cx="150" cy="44" r="2" fill="#f0abfc" />
</svg>
</template>
<style scoped>
.mascot { display: block; }
.mascot-float { animation: mascotFloat 5s ease-in-out infinite; transform-origin: center; }
@keyframes mascotFloat {
0%, 100% { transform: translateY(0) rotate(-1deg); }
50% { transform: translateY(-8px) rotate(1.5deg); }
}
@media (prefers-reduced-motion: reduce) { .mascot-float { animation: none; } }
</style>
+1 -1
View File
@@ -52,7 +52,7 @@ watch(() => route.path, () => {
<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">
<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" />
<Logo v-else :size="32" />
<div class="leading-tight min-w-0">
<div class="text-sm font-semibold truncate tracking-tight text-[color:var(--fg)]">{{ site.title }}</div>
<div class="text-[11px] text-[color:var(--fg-3)] truncate">Admin</div>
+260 -124
View File
@@ -1,7 +1,7 @@
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { auth, isAuthed, isAdmin, openLogin } from '../auth'
import { auth, isAuthed, isAdmin, openLogin, openRegister, logout } from '../auth'
import { isDark, toggleTheme } from '../theme'
import { site } from '../site'
import Icon from '../components/Icon.vue'
@@ -12,96 +12,157 @@ import { announcement, openAnnouncement } from '../announcement'
const route = useRoute()
// 画图/记录 only show once signed in; clicking 设置 while logged out opens login.
// Top-bar links. 画图/图片 only show once signed in; everything else a
// signed-in user needs lives in the avatar menu so the bar stays short.
const nav = computed(() => {
const items = [{ to: '/', label: '首页', icon: 'overview' }]
const items = [{ to: '/', label: '首页' }]
if (isAuthed()) {
items.push({ to: '/user', label: '画图', icon: 'spark' })
items.push({ to: '/logs', label: '图片', icon: 'files' })
items.push({ to: '/mylogs', label: '日志', icon: 'log' })
items.push({ to: '/invite', label: '邀请', icon: 'accounts' })
items.push({ to: '/orders', label: '订单', icon: 'receipt' })
items.push({ to: '/user', label: '画图' })
items.push({ to: '/logs', label: '图片' })
}
// 文档 + 关于 are public — visible to guests too.
items.push({ to: '/docs', label: '文档', icon: 'book' })
items.push({ to: '/about', label: '关于', icon: 'info' })
items.push({ to: '/docs', label: '文档' })
items.push({ to: '/about', label: '关于' })
return items
})
function onSettings(e) {
if (!isAuthed()) { e.preventDefault(); openLogin('/settings') }
}
// Avatar menu — only the surfaces the top nav doesn't already expose
// (画图/图片 live in the bar, so they're intentionally left out here).
const userMenu = [
{ to: '/mylogs', label: '生成日志' },
{ to: '/invite', label: '邀请返利' },
{ to: '/orders', label: '订单' },
{ to: '/settings', label: '设置' },
]
const menuOpen = ref(false) // avatar dropdown
const mobileOpen = ref(false) // hamburger panel
watch(() => route.fullPath, () => { menuOpen.value = false; mobileOpen.value = false })
// credits — the logged-in user's real server-side balance (auth.user.credits)
const credits = computed(() => Number(auth.user?.credits || 0))
const showBalance = computed(() => route.path === '/user')
const creditsLabel = computed(() => pointsLabel(credits.value))
const displayName = computed(() => {
const n = (auth.user?.name || '').trim()
if (n) return n
const e = (auth.user?.email || '').trim()
return e ? e.split('@')[0] : '我的'
})
// On the 画图 workbench the header label tracks the active mode — it flips
// with the 生图/生视频 tab, and on state restore (回显) reflects whatever a
// pending job is generating (video → 生视频). draft.mode is kept in sync by
// PlaygroundView for both cases.
// On the 画图 workbench the label tracks the active mode — it flips with the
// 生图/生视频 tab, and on state restore reflects a pending job's kind.
const currentLabel = computed(() => {
if (route.path === '/user') return draft.mode === 'video' ? '生视频' : '生图'
return route.meta?.label || ''
})
// The home page paints its own full-bleed hero; other pages keep the padded
// shell plus a route-label header.
const isHome = computed(() => route.path === '/')
async function onLogout() {
menuOpen.value = false
await logout()
}
</script>
<template>
<div class="theme-x min-h-screen flex bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-violet-400/30">
<!-- ===== Left rail ===== -->
<aside class="fixed inset-y-0 left-0 z-30 w-16 md:w-20 flex flex-col items-center py-5 border-r border-[color:var(--hairline)]">
<!-- Logo -->
<router-link to="/" class="mb-8 group transition-transform hover:scale-105">
<img v-if="site.logo" :src="site.logo" :alt="site.title"
class="w-10 h-10 rounded-xl object-contain shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
<Logo v-else :size="40" class="rounded-xl shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
<div class="theme-x min-h-screen bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-violet-400/30">
<!-- ===== Top bar ===== -->
<header class="fixed top-0 inset-x-0 z-40 border-b border-transparent backdrop-blur-xl top-bar">
<div class="mx-auto max-w-[1400px] px-4 md:px-8 h-[58px] flex items-center gap-3">
<!-- brand -->
<router-link to="/" class="flex items-center gap-2.5 shrink-0 transition-transform hover:scale-[1.03]">
<img v-if="site.logo" :src="site.logo" :alt="site.title" class="w-8 h-8 object-contain" />
<Logo v-else :size="30" />
<span class="text-[17px] font-bold tracking-tight text-[color:var(--fg)]">{{ site.title }}</span>
<span class="brand-spark" aria-hidden="true"></span>
</router-link>
<!-- Nav -->
<nav class="flex flex-col gap-1.5 flex-1">
<router-link
v-for="n in nav" :key="n.to" :to="n.to"
:exact-active-class="n.to === '/' ? 'active' : ''"
:active-class="n.to === '/' ? '' : 'active'"
:title="n.label"
class="rail-link group">
<span class="w-10 h-10 rounded-xl grid place-items-center transition-all ring-1 ring-transparent group-hover:bg-[var(--hover)] group-hover:ring-[color:var(--hairline)]">
<Icon :name="n.icon" class="w-4 h-4 transition-colors" />
</span>
</router-link>
<!-- links (desktop) -->
<nav class="hidden md:flex items-center gap-0.5 ml-4">
<router-link v-for="n in nav" :key="n.to" :to="n.to"
:exact-active-class="n.to === '/' ? 'on' : ''"
:active-class="n.to === '/' ? '' : 'on'"
class="top-link">{{ n.label }}</router-link>
</nav>
<!-- Bottom: admin shortcut (admins only) + 公告 + settings -->
<div class="flex flex-col items-center gap-3">
<router-link v-if="isAdmin()" to="/admin/overview" title="进入管理后台"
class="rail-bottom">
<Icon name="shield" class="w-4 h-4" />
<div class="flex-1"></div>
<!-- balance (signed in) -->
<router-link v-if="isAuthed()" to="/settings"
class="hidden sm:block text-xs text-[color:var(--fg-2)] hover:text-[color:var(--fg)] tabular-nums transition-colors">
余额 <span class="text-[color:var(--fg)] font-semibold">{{ creditsLabel }}</span>
</router-link>
<!-- Light/dark toggle sits right above 设置. Sun when dark (clicklight),
moon when light (clickdark). -->
<button type="button" @click="toggleTheme"
:title="isDark ? '切换到亮色' : '切换到暗色'" class="rail-bottom">
<!-- theme -->
<button type="button" @click="toggleTheme" class="round-btn"
:title="isDark ? '切换到亮色' : '切换到暗色'">
<svg v-if="isDark" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
<!-- 公告 sits right above 设置. Only for logged-in users, and only
when there actually is an announcement to show. -->
<!-- 公告 -->
<button v-if="isAuthed() && announcement.content.trim()" type="button"
@click="openAnnouncement" title="公告" class="rail-bottom">
@click="openAnnouncement" class="round-btn" title="公告">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l18-7-4 14-6-3-3 4-1-6z"/></svg>
</button>
<router-link to="/settings" title="设置" @click="onSettings"
:class="$route.path === '/settings' ? 'rail-bottom active' : 'rail-bottom'">
<Icon name="config" class="w-4 h-4" />
<!-- admin shortcut -->
<router-link v-if="isAdmin()" to="/admin/overview" class="round-btn" title="进入管理后台">
<Icon name="shield" class="w-4 h-4" />
</router-link>
<!-- guest actions -->
<template v-if="!isAuthed()">
<button type="button" class="hidden sm:inline-flex top-link" @click="openLogin('/user')">登录</button>
<button type="button" class="solid-btn" @click="openRegister('', '/user')">
免费开始 <span aria-hidden="true"></span>
</button>
</template>
<!-- signed-in: avatar menu -->
<div v-else class="relative">
<button type="button" class="name-btn" @click.stop="menuOpen = !menuOpen" title="我的">
<span class="name-txt">{{ displayName }}</span>
<svg class="w-3.5 h-3.5 shrink-0 transition-transform" :class="menuOpen && 'rotate-180'"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div v-if="menuOpen" class="menu" @click.stop>
<router-link v-for="m in userMenu" :key="m.to" :to="m.to" class="menu-item">{{ m.label }}</router-link>
<div class="menu-sep"></div>
<button type="button" class="menu-item w-full text-left" @click="onLogout">退出登录</button>
</div>
</aside>
</div>
<!-- hamburger -->
<button type="button" class="round-btn hamburger" @click.stop="mobileOpen = !mobileOpen" aria-label="菜单">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
</div>
<!-- mobile panel -->
<div v-if="mobileOpen" class="md:hidden border-t border-[color:var(--hairline)] px-3 py-3 bg-[var(--menu-bg)]">
<router-link v-for="n in nav" :key="n.to" :to="n.to"
:exact-active-class="n.to === '/' ? 'm-on' : ''"
:active-class="n.to === '/' ? '' : 'm-on'"
class="m-link">{{ n.label }}</router-link>
<template v-if="isAuthed()">
<div class="menu-sep"></div>
<router-link v-for="m in userMenu" :key="m.to" :to="m.to" active-class="m-on" class="m-link">{{ m.label }}</router-link>
</template>
<template v-else>
<div class="menu-sep"></div>
<button type="button" class="m-link w-full text-left" @click="openLogin('/user')">登录</button>
</template>
</div>
</header>
<!-- click-away for the dropdowns -->
<div v-if="menuOpen || mobileOpen" class="fixed inset-0 z-30"
@click="menuOpen = false; mobileOpen = false"></div>
<!-- ===== Main column ===== -->
<div class="flex-1 min-w-0 ml-16 md:ml-20 relative">
<div class="relative">
<!-- soft background mesh -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="absolute -top-32 left-1/3 w-[40rem] h-[40rem] rounded-full opacity-[0.16]"
@@ -110,26 +171,17 @@ const currentLabel = computed(() => {
style="background: radial-gradient(circle, #06b6d4, transparent 60%); filter: blur(100px)"></div>
<div class="absolute bottom-0 left-0 w-[32rem] h-[32rem] rounded-full opacity-[0.10]"
style="background: radial-gradient(circle, #f43f5e, transparent 60%); filter: blur(100px)"></div>
<div class="anime-stars"></div>
</div>
<!-- Page header shows the Vivid wordmark on home, route label
elsewhere. Same vertical position across routes so the brand
stamp doesn't jump around. -->
<header class="relative z-10 px-8 md:px-14 pt-10 pb-4 flex items-center justify-between gap-4">
<div class="flex items-baseline gap-2">
<span class="text-[22px] font-bold tracking-tight bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent">
{{ site.title }}
</span>
<span class="text-[10px] uppercase tracking-[0.3em] text-[color:var(--fg-faint)]">{{ route.path === '/' ? 'AI 生图 · 生视频' : currentLabel }}</span>
</div>
<router-link v-if="showBalance" to="/settings"
class="text-xs text-[color:var(--fg-2)] hover:text-[color:var(--fg)] tabular-nums transition-colors">
余额 <span class="text-[color:var(--fg)] font-semibold">{{ creditsLabel }}</span>
<span class="text-[color:var(--fg-faint)] ml-1">· 充值</span>
</router-link>
<!-- Route label header the home page brings its own hero instead. -->
<header v-if="!isHome" class="relative z-10 mx-auto max-w-[1400px] px-4 md:px-8 pt-[86px] pb-4">
<span class="text-[22px] font-bold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</span>
</header>
<main :class="['relative z-10 px-8 md:px-14 pb-24 pt-2', { 'public-dark': isDark }]">
<main :class="['relative z-10 mx-auto max-w-[1400px] pb-24',
isHome ? 'pt-[58px] px-0' : 'px-4 md:px-8 pt-2',
{ 'public-dark': isDark }]">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
@@ -141,70 +193,154 @@ const currentLabel = computed(() => {
</template>
<style scoped>
.rail-link {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.25rem 0;
color: var(--fg-3);
transition: color 0.15s ease;
.top-bar {
background: color-mix(in srgb, var(--app-bg) 72%, transparent);
transition: border-color 0.2s ease;
}
.rail-link:hover { color: var(--fg); }
.rail-link.active { color: var(--fg); }
.rail-link.active::before {
content: '';
position: absolute;
left: -1rem;
/* Anchor to the icon box, not the whole link (which includes the label
underneath). rail-link has padding-top 0.25rem and the icon span is 2.5rem
tall — so the icon center sits at 0.25rem + 1.25rem = 1.5rem from top. */
top: 1.5rem;
transform: translateY(-50%);
width: 3px;
height: 24px;
border-radius: 4px;
background: linear-gradient(180deg, #f0abfc, #a78bfa);
.top-bar:hover { border-bottom-color: var(--hairline); }
.top-link {
padding: 0.45rem 0.8rem;
border-radius: 999px;
font-size: 13.5px;
font-weight: 500;
color: var(--fg-2);
background: transparent;
border: none;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.rail-link.active > span:first-child {
background: var(--hover) !important;
--tw-ring-color: var(--hairline);
.top-link:hover { color: var(--fg); background: var(--hover); }
.top-link.on {
color: #a21caf;
font-weight: 700;
background: linear-gradient(135deg, rgb(244 114 182 / 0.16), rgb(129 140 248 / 0.16));
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.3);
}
html.dark .top-link.on {
color: #f5d0fe;
background: linear-gradient(135deg, rgb(244 114 182 / 0.2), rgb(129 140 248 / 0.2));
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.35), 0 0 16px -6px rgb(217 70 239 / 0.55);
}
.rail-bottom {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
.brand-spark {
font-size: 12px;
line-height: 1;
background: linear-gradient(135deg, #f472b6, #38bdf8);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: brandSpark 3.5s ease-in-out infinite;
}
@keyframes brandSpark {
0%, 100% { opacity: 0.55; transform: scale(0.9) rotate(0deg); }
50% { opacity: 1; transform: scale(1.15) rotate(20deg); }
}
@media (prefers-reduced-motion: reduce) { .brand-spark { animation: none; } }
.m-link {
display: block;
padding: 0.6rem 0.75rem;
border-radius: 0.85rem;
font-size: 14px;
color: var(--fg-2);
background: transparent;
border: none;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.m-link:hover { color: var(--fg); background: var(--hover); }
.m-link.m-on {
color: #a21caf;
font-weight: 700;
background: linear-gradient(135deg, rgb(244 114 182 / 0.16), rgb(129 140 248 / 0.16));
}
html.dark .m-link.m-on { color: #f5d0fe; background: linear-gradient(135deg, rgb(244 114 182 / 0.2), rgb(129 140 248 / 0.2)); }
.round-btn {
width: 2.125rem;
height: 2.125rem;
border-radius: 999px;
display: grid;
place-items: center;
color: var(--fg-3);
--tw-ring-color: transparent;
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.rail-bottom:hover {
color: var(--fg);
background: var(--hover);
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: color 0.15s ease, background 0.15s ease;
}
.rail-bottom.active {
.round-btn:hover { color: var(--fg); background: var(--hover); }
/* mobile-only: .round-btn's display:grid outranks Tailwind's md:hidden, so
the breakpoint lives here instead */
@media (min-width: 768px) { .hamburger { display: none; } }
.solid-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1.1rem;
border-radius: 999px;
font-size: 13.5px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #f472b6, #a855f7 55%, #38bdf8);
border: none;
cursor: pointer;
box-shadow: 0 12px 26px -14px rgb(168 85 247 / 0.75);
transition: transform 0.15s ease, filter 0.15s ease, box-shadow 0.15s ease;
}
.solid-btn:hover { filter: brightness(1.06); transform: translateY(-1px); box-shadow: 0 16px 30px -14px rgb(168 85 247 / 0.85); }
.name-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
max-width: 10rem;
height: 2.125rem;
padding: 0 0.7rem 0 0.85rem;
border-radius: 999px;
border: none;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: var(--fg);
background: var(--hover);
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s ease, box-shadow 0.15s ease;
}
.name-btn:hover {
background: linear-gradient(135deg, rgb(244 114 182 / 0.14), rgb(129 140 248 / 0.14));
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.3);
}
.name-txt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Admin shortcut — only rendered for admins. Tinted so it doesn't get lost
next to the plain settings cog right below it. */
.admin-shortcut {
color: rgb(196 181 253); /* violet-300 */
background: linear-gradient(135deg, rgb(167 139 250 / 0.12), rgb(236 72 153 / 0.10));
box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.25);
.menu {
position: absolute;
right: 0;
top: 2.75rem;
width: 11.5rem;
padding: 0.4rem;
border-radius: 0.9rem;
background: var(--menu-bg);
box-shadow: inset 0 0 0 1px var(--hairline), 0 20px 44px rgb(0 0 0 / 0.24);
z-index: 50;
}
.admin-shortcut:hover {
color: white;
background: linear-gradient(135deg, rgb(167 139 250 / 0.22), rgb(236 72 153 / 0.18));
box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.45);
.menu-item {
display: block;
padding: 0.5rem 0.65rem;
border-radius: 0.55rem;
font-size: 13px;
color: var(--fg-2);
background: transparent;
border: none;
cursor: pointer;
}
.menu-item:hover { background: var(--hover); color: var(--fg); }
.menu-sep { height: 1px; background: var(--hairline); margin: 0.35rem 0.25rem; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.fade-enter-from { opacity: 0; transform: translateY(8px); }
+27 -23
View File
@@ -6,31 +6,35 @@ import { auth, refreshMe, openLogin } from './auth'
import { site, loadSite } from './site'
import PublicLayout from './layouts/PublicLayout.vue'
import AdminLayout from './layouts/AdminLayout.vue'
// Route-level code splitting: a guest landing on the home page must not
// download the whole admin console. Only the public shell + HomeView are in
// the entry chunk; every other view (and the admin shell) is fetched on demand.
const AdminLayout = () => import('./layouts/AdminLayout.vue')
import HomeView from './views/HomeView.vue'
import PlaygroundView from './views/PlaygroundView.vue'
import UserLogsView from './views/UserLogsView.vue'
import UserLogsTableView from './views/UserLogsTableView.vue'
import SettingsView from './views/SettingsView.vue'
import OrdersView from './views/OrdersView.vue'
import AdminOrdersView from './views/AdminOrdersView.vue'
import InviteView from './views/InviteView.vue'
import DocsView from './views/DocsView.vue'
import AboutView from './views/AboutView.vue'
import OverviewView from './views/OverviewView.vue'
import ModelsView from './views/ModelsView.vue'
import AccountsView from './views/AccountsView.vue'
import UsersView from './views/UsersView.vue'
import ConcurrencyView from './views/ConcurrencyView.vue'
import BannedWordsView from './views/BannedWordsView.vue'
import BannedWordHitsView from './views/BannedWordHitsView.vue'
import CdksView from './views/CdksView.vue'
import InvitesAdminView from './views/InvitesAdminView.vue'
import ImagesView from './views/ImagesView.vue'
import LogsView from './views/LogsView.vue'
import ConfigView from './views/ConfigView.vue'
import ShowcaseView from './views/ShowcaseView.vue'
const PlaygroundView = () => import('./views/PlaygroundView.vue')
const UserLogsView = () => import('./views/UserLogsView.vue')
const UserLogsTableView = () => import('./views/UserLogsTableView.vue')
const SettingsView = () => import('./views/SettingsView.vue')
const OrdersView = () => import('./views/OrdersView.vue')
const AdminOrdersView = () => import('./views/AdminOrdersView.vue')
const InviteView = () => import('./views/InviteView.vue')
const DocsView = () => import('./views/DocsView.vue')
const AboutView = () => import('./views/AboutView.vue')
const OverviewView = () => import('./views/OverviewView.vue')
const ModelsView = () => import('./views/ModelsView.vue')
const AccountsView = () => import('./views/AccountsView.vue')
const UsersView = () => import('./views/UsersView.vue')
const ConcurrencyView = () => import('./views/ConcurrencyView.vue')
const BannedWordsView = () => import('./views/BannedWordsView.vue')
const BannedWordHitsView = () => import('./views/BannedWordHitsView.vue')
const CdksView = () => import('./views/CdksView.vue')
const InvitesAdminView = () => import('./views/InvitesAdminView.vue')
const ImagesView = () => import('./views/ImagesView.vue')
const LogsView = () => import('./views/LogsView.vue')
const ConfigView = () => import('./views/ConfigView.vue')
const ShowcaseView = () => import('./views/ShowcaseView.vue')
const routes = [
{
+128
View File
@@ -127,6 +127,8 @@ html:not(.dark) .theme-text .fp-rose { background: rgb(244 63 94 / 0.12); color:
html:not(.dark) .theme-text .fp-fuchsia { background: rgb(217 70 239 / 0.12); color: rgb(162 28 175); box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.45); }
html:not(.dark) .theme-text .fp-amber { background: rgb(245 158 11 / 0.16); color: rgb(180 83 9); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.5); }
html:not(.dark) .theme-text .fp-violet { background: rgb(139 92 246 / 0.14); color: rgb(109 40 217); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.45); }
html:not(.dark) .theme-text .fp-purple { background: rgb(168 85 247 / 0.14); color: rgb(107 33 168); box-shadow: inset 0 0 0 1px rgb(168 85 247 / 0.45); }
html:not(.dark) .theme-text .fp-slate { background: rgb(100 116 139 / 0.14); color: rgb(51 65 85); box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.45); }
html:not(.dark) .theme-text .fp-sky { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.45); }
html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color: rgb(15 118 110); box-shadow: inset 0 0 0 1px rgb(20 184 166 / 0.45); }
@@ -164,6 +166,28 @@ html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color
margin-top: -0.12rem;
}
.chk:focus-visible { outline: 2px solid rgb(217 70 239 / 0.45); outline-offset: 1px; }
/* Account identity tags (AccountsView: 子号 / 母号 / 普号 / 限额). The colors live
here rather than as inline dark-palette utilities, which were invisible on the
light theme: light values first, html.dark override after. */
.acct-tag {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
font-size: 10px;
line-height: 1.5;
font-weight: 500;
white-space: nowrap;
}
.acct-tag-sub { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.4); }
.acct-tag-master { background: rgb(139 92 246 / 0.14); color: rgb(109 40 217); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.4); }
.acct-tag-free { background: rgb(100 116 139 / 0.14); color: rgb(51 65 85); box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.4); }
.acct-tag-limit { background: rgb(245 158 11 / 0.16); color: rgb(180 83 9); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.4); }
html.dark .acct-tag-sub { background: rgb(14 165 233 / 0.18); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.35); }
html.dark .acct-tag-master { background: rgb(139 92 246 / 0.22); color: rgb(196 181 253); box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.35); }
html.dark .acct-tag-free { background: rgb(148 163 184 / 0.22); color: rgb(226 232 240); box-shadow: inset 0 0 0 1px rgb(148 163 184 / 0.35); }
html.dark .acct-tag-limit { background: rgb(245 158 11 / 0.18); color: rgb(252 211 77); box-shadow: inset 0 0 0 1px rgb(251 191 36 / 0.35); }
/* Capability chips (ModelsView): all variants need bg + ring + color reset in light mode.
The scoped dark-shell values (white-alpha bg/ring, pale text) are invisible on white. */
html:not(.dark) .theme-text .cap-emerald {
@@ -274,6 +298,110 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi
radial-gradient(at 50% 90%, rgb(236 254 255) 0%, transparent 50%),
rgb(250 250 251);
}
/* ===== 二次元 (anime) design kit =====
Soft pink/violet/sky gradients, rounded bubbly surfaces, sparkle overlays and
sticker labels. Reusable across the workbench + user subpages + public pages
so the whole front-end shares one cute, neon-tinged look. */
/* Gradient text used for eyebrows / accents. */
.anime-grad-text {
background: linear-gradient(100deg, #f472b6, #a855f7 46%, #38bdf8);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Section eyebrow — tiny uppercase kicker with a leading star. */
.anime-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.anime-eyebrow::before { content: '✦'; font-size: 0.85em; }
/* Sticker / kawaii pill label. */
.sticker {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.28rem 0.7rem;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
color: #a21caf;
background: linear-gradient(135deg, rgb(244 114 182 / 0.16), rgb(129 140 248 / 0.16));
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.28);
}
html.dark .sticker {
color: #f5d0fe;
background: linear-gradient(135deg, rgb(244 114 182 / 0.2), rgb(129 140 248 / 0.2));
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.35), 0 0 18px -6px rgb(217 70 239 / 0.5);
}
/* Bubbly gradient CTA — pill button with a squishy hover. */
.bubble-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.4rem;
border-radius: 999px;
font-size: 14px;
font-weight: 700;
color: #fff;
border: none;
cursor: pointer;
background: linear-gradient(135deg, #f472b6, #a855f7 55%, #38bdf8);
box-shadow: 0 14px 30px -12px rgb(168 85 247 / 0.7);
transition: transform 0.15s ease, filter 0.15s ease, box-shadow 0.15s ease;
}
.bubble-btn:hover { transform: translateY(-2px) scale(1.02); filter: brightness(1.06); }
.bubble-btn:active { transform: translateY(0) scale(0.99); }
/* Rounded anime card surface with a soft pastel wash + neon ring in dark. */
.anime-card {
position: relative;
border-radius: 1.5rem;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.anime-card:hover {
transform: translateY(-3px);
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.4), 0 22px 44px -26px rgb(168 85 247 / 0.5);
}
html.dark .anime-card:hover {
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.5), 0 0 30px -10px rgb(217 70 239 / 0.45);
}
/* Selected pill — replaces the old bg-slate-900 look with a candy gradient. */
.chip-on {
color: #fff !important;
background: linear-gradient(135deg, #e879f9, #8b5cf6) !important;
box-shadow: 0 8px 20px -10px rgb(168 85 247 / 0.7) !important;
}
/* Twinkling star field — drop into any relatively-positioned decorative layer.
Uses layered radial-gradients so it's a single element, no extra DOM. */
.anime-stars {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
radial-gradient(1.5px 1.5px at 12% 22%, rgb(217 70 239 / 0.7), transparent),
radial-gradient(1.5px 1.5px at 78% 12%, rgb(56 189 248 / 0.7), transparent),
radial-gradient(1px 1px at 42% 62%, rgb(168 85 247 / 0.65), transparent),
radial-gradient(1.5px 1.5px at 88% 72%, rgb(244 114 182 / 0.6), transparent),
radial-gradient(1px 1px at 24% 84%, rgb(56 189 248 / 0.55), transparent),
radial-gradient(1px 1px at 62% 34%, rgb(250 204 21 / 0.55), transparent);
animation: twinkle 4.5s ease-in-out infinite;
}
@keyframes twinkle { 0%, 100% { opacity: 0.85; } 50% { opacity: 0.4; } }
@media (prefers-reduced-motion: reduce) { .anime-stars { animation: none; } }
.ambient-grain::before {
content: '';
position: fixed;
+62 -28
View File
@@ -1,67 +1,101 @@
<script setup>
import { computed } from 'vue'
import { site } from '../site'
import Mascot from '../components/Mascot.vue'
const contact = computed(() => site.contact || {})
const powers = [
{ icon: '🎨', title: '文字生图', desc: '一句话出图,画幅与分辨率随模型可选。', tint: 'from-fuchsia-400/20 to-pink-400/10' },
{ icon: '🖼️', title: '参考图生图', desc: '上传参考换风格、改构图、做变体。', tint: 'from-violet-400/20 to-indigo-400/10' },
{ icon: '🎬', title: '文字生视频', desc: '一段描述生成短片,时长分辨率可选。', tint: 'from-sky-400/20 to-cyan-400/10' },
{ icon: '✨', title: '图生视频', desc: '给张参考图,让静态画面动起来。', tint: 'from-amber-400/20 to-orange-400/10' },
]
</script>
<template>
<div class="space-y-12 max-w-3xl">
<!-- intro -->
<section>
<div class="text-[10px] uppercase tracking-[0.3em] text-fuchsia-300/70 font-medium">关于</div>
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight text-[color:var(--fg)]">关于 {{ site.title }}</h1>
<div class="space-y-16 pb-8">
<!-- ===== hero ===== -->
<section class="relative overflow-hidden rounded-[2rem] anime-card px-6 py-10 md:px-12 md:py-14">
<div class="anime-stars"></div>
<div class="relative grid md:grid-cols-[1.1fr_0.9fr] gap-8 items-center">
<div>
<span class="sticker"> 关于我们</span>
<h1 class="mt-4 text-4xl md:text-5xl font-extrabold tracking-tight text-[color:var(--fg)]">
我是 <span class="anime-grad-text">薇薇</span>
</h1>
<p class="text-[color:var(--fg-2)] mt-5 text-base md:text-lg leading-relaxed max-w-xl">
{{ site.title }} 是一个聚合 GPTGeminiFireflyFlux 等顶级模型的 AI 生图与生视频平台
把脑海里的画面写成一句话,交给我替你变成图像与视频
把脑海里的画面写成一句话交给我替你变成图像与视频
</p>
<router-link to="/user" class="bubble-btn mt-7">开始创作 </router-link>
</div>
<div class="grid place-items-center">
<Mascot :size="240" />
</div>
</div>
</section>
<!-- contact -->
<!-- ===== powers ===== -->
<section>
<h2 class="text-2xl font-bold tracking-tight text-[color:var(--fg)] mb-1">联系我们</h2>
<p class="text-[color:var(--fg-3)] mb-6">有问题合作,或想加入交流群?随时找我们</p>
<div class="anime-eyebrow text-fuchsia-500 dark:text-fuchsia-300">能做什么</div>
<h2 class="mt-3 text-2xl md:text-3xl font-extrabold tracking-tight text-[color:var(--fg)]">四种方式把想象变成画面</h2>
<div class="mt-7 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div v-for="p in powers" :key="p.title"
class="anime-card p-5 bg-gradient-to-br" :class="p.tint">
<div class="text-3xl">{{ p.icon }}</div>
<div class="mt-3 text-base font-bold text-[color:var(--fg)]">{{ p.title }}</div>
<div class="mt-1.5 text-[13px] leading-relaxed text-[color:var(--fg-3)]">{{ p.desc }}</div>
</div>
</div>
</section>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<!-- ===== contact ===== -->
<section>
<div class="anime-eyebrow text-sky-500 dark:text-sky-300">联系我们</div>
<h2 class="mt-3 text-2xl md:text-3xl font-extrabold tracking-tight text-[color:var(--fg)]">有问题来找我们玩</h2>
<p class="text-[color:var(--fg-3)] mt-2 mb-6">合作反馈或想加入交流群随时招呼</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-3xl">
<a v-if="contact.shop" :href="contact.shop" target="_blank" rel="noopener"
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
class="group anime-card flex items-center justify-between gap-3 p-5">
<div>
<div class="text-[10px] uppercase tracking-[0.25em] text-fuchsia-300/80">商店</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 group-hover:text-fuchsia-400 transition-colors">前往商店</div>
<div class="sticker">🛍 商店</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 group-hover:text-fuchsia-400 transition-colors">前往商店</div>
</div>
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform"></span>
</a>
<a v-if="contact.email" :href="`mailto:${contact.email}`"
class="group rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">邮箱</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 break-all group-hover:text-sky-400 transition-colors">{{ contact.email }}</div>
class="group anime-card p-5">
<div class="sticker"> 邮箱</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 break-all group-hover:text-sky-400 transition-colors">{{ contact.email }}</div>
</a>
<a v-if="contact.qq && contact.qq_link" :href="contact.qq_link" target="_blank" rel="noopener"
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
class="group anime-card flex items-center justify-between gap-3 p-5">
<div>
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all group-hover:text-sky-400 transition-colors">{{ contact.qq }}</div>
<div class="sticker">💬 QQ</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all group-hover:text-sky-400 transition-colors">{{ contact.qq }}</div>
</div>
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform"></span>
</a>
<div v-else-if="contact.qq" class="rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5">
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all">{{ contact.qq }}</div>
<div v-else-if="contact.qq" class="anime-card p-5">
<div class="sticker">💬 QQ</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all">{{ contact.qq }}</div>
</div>
<a v-if="contact.qq_group && contact.qq_group_link" :href="contact.qq_group_link" target="_blank" rel="noopener"
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
class="group anime-card flex items-center justify-between gap-3 p-5">
<div>
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ </div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all group-hover:text-emerald-400 transition-colors">{{ contact.qq_group }}</div>
<div class="sticker">🎀 QQ </div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all group-hover:text-emerald-400 transition-colors">{{ contact.qq_group }}</div>
</div>
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform"></span>
</a>
<div v-else-if="contact.qq_group" class="rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5">
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ </div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all">{{ contact.qq_group }}</div>
<div v-else-if="contact.qq_group" class="anime-card p-5">
<div class="sticker">🎀 QQ </div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all">{{ contact.qq_group }}</div>
</div>
</div>
+44 -9
View File
@@ -35,6 +35,7 @@ function applyEdit(payload) {
const typeFilter = ref('') // '' | 'openai' | 'adobe' | 'runway' | 'leonardo'
const statusFilter = ref('') // '' | 'active' | 'quota' | 'disabled'
const planFilter = ref('') // '' | 'vip' | 'free' | 'sub' | 'master'
const search = ref('')
const page = ref(1)
@@ -70,6 +71,14 @@ function typePill(t) {
imagine: 'bg-teal-500/10 text-teal-300 ring-teal-400/30',
}[t] || 'bg-white/[0.06] text-white/70 ring-white/15'
}
// 与后端 accountConcurrency 保持一致:adobe 普号固定 1 并发,会员号取配置值
// (未配置时默认 5);custom 上游取配置值 (默认 1)grok 固定 10,其余 1。
function acctConcurrency(a) {
if (a.type === 'adobe') return a.plan === 'free' ? 1 : (Number(a.concurrency) || 5)
if (a.type === 'custom') return Number(a.concurrency) || 1
return a.type === 'grok' ? 10 : 1
}
const STATUS_LABEL = { active: '正常', quota: '额度耗尽', disabled: '已禁用', pending: '检测中' }
// Server-side pagination: rows IS the current page, already filtered/sorted
@@ -112,6 +121,7 @@ function buildQs() {
})
if (typeFilter.value) qs.set('type', typeFilter.value)
if (statusFilter.value) qs.set('status', statusFilter.value)
if (planFilter.value && typeFilter.value === 'adobe') qs.set('plan', planFilter.value)
if (search.value.trim()) qs.set('q', search.value.trim())
return qs.toString()
}
@@ -250,6 +260,9 @@ function applyQuota(row, result) {
row._unknown = false
row.remaining = result.remaining
row.reset_after = result.reset_after
// 会员身份随额度一起回写:改过会员/掉了会员的号,查一次额度徽章就跟着更新。
if (result.plan !== undefined) row.plan = result.plan
if (result.sub_account !== undefined) row.sub_account = result.sub_account
}
async function toggleAccountStatus(pool, id, current) {
@@ -372,6 +385,15 @@ onMounted(() => { loadAccounts(); loadModelList() })
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>已禁用
</button>
</div>
<template v-if="typeFilter === 'adobe'">
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="setFilter(() => planFilter = '')" class="fp" :class="planFilter === '' && 'fp-on'">全部账号</button>
<button @click="setFilter(() => planFilter = 'free')" class="fp" :class="planFilter === 'free' && 'fp-slate'">普号</button>
<button @click="setFilter(() => planFilter = 'sub')" class="fp" :class="planFilter === 'sub' && 'fp-sky'">子号</button>
<button @click="setFilter(() => planFilter = 'master')" class="fp" :class="planFilter === 'master' && 'fp-purple'">母号</button>
</div>
</template>
<div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / ID / 类型…" />
</div>
@@ -453,14 +475,17 @@ onMounted(() => { loadAccounts(); loadModelList() })
case. -->
<div class="flex items-center gap-2 min-w-0">
<span class="text-sm text-white/90 truncate" :title="a.email || '-'">{{ a.email || '-' }}</span>
<span v-if="a.sub_account"
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-sky-500/15 text-sky-300 ring-1 ring-sky-400/20"
:title="'子号 (剩余 ' + (a.remaining ?? '—') + ' 积分)'">子号</span>
<span v-if="a.image_limited && a.status !== 'quota'"
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20"
<!-- 身份互斥子号必然是会员号所以 子号 > 母号 > 普号 只取一个
plan 未知(还没查过额度)时不猜先不显示 -->
<span v-if="a.sub_account" class="acct-tag acct-tag-sub"
:title="'子号 · 会员 (剩余 ' + (a.remaining ?? '—') + ' 积分)'">子号</span>
<span v-else-if="a.plan && a.plan !== 'free'" class="acct-tag acct-tag-master"
:title="'母号 · 会员 (' + a.plan + ')'">母号</span>
<span v-else-if="a.plan === 'free'" class="acct-tag acct-tag-free"
title="普号 (无会员)">普号</span>
<span v-if="a.image_limited && a.status !== 'quota' && a.plan !== 'free'" class="acct-tag acct-tag-limit"
title="图片额度耗尽,仅视频可用">图片限额</span>
<span v-if="a.video_limited && a.status !== 'quota'"
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20"
<span v-if="a.video_limited && a.status !== 'quota' && a.plan !== 'free'" class="acct-tag acct-tag-limit"
title="视频额度耗尽,仅图片可用">视频限额</span>
</div>
</td>
@@ -484,8 +509,8 @@ onMounted(() => { loadAccounts(); loadModelList() })
</td>
<!-- concurrency (custom = configured value; others = system fixed) -->
<td class="px-3 py-3.5 align-middle text-center whitespace-nowrap text-xs tabular-nums">
<span v-if="a.type === 'custom'" class="text-white/70">{{ a.concurrency || 1 }}</span>
<span v-else class="text-white/25" title="系统固定">{{ a.type === 'grok' ? 10 : 1 }}</span>
<span v-if="a.type === 'custom'" class="text-white/70">{{ acctConcurrency(a) }}</span>
<span v-else class="text-white/25" title="系统固定">{{ acctConcurrency(a) }}</span>
</td>
<!-- reset_after -->
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
@@ -622,6 +647,16 @@ onMounted(() => { loadAccounts(); loadModelList() })
color: rgb(94 234 212);
box-shadow: inset 0 0 0 1px rgb(94 234 212 / 0.45);
}
.fp-purple {
background: rgb(168 85 247 / 0.22);
color: rgb(216 180 254);
box-shadow: inset 0 0 0 1px rgb(216 180 254 / 0.45);
}
.fp-slate {
background: rgb(148 163 184 / 0.22);
color: rgb(203 213 225);
box-shadow: inset 0 0 0 1px rgb(203 213 225 / 0.4);
}
/* --- icon-only action buttons */
.act {
+90 -14
View File
@@ -5,7 +5,6 @@
import { ref, computed, onMounted } from 'vue'
import { auth } from '../auth'
import { api } from '../api'
import { points } from '../credits'
import Icon from '../components/Icon.vue'
const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy)
@@ -26,6 +25,11 @@ const sampleImage = computed(() => pubName(imageModels.value[0]) || 'firefly-ima
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'firefly-kling3')
const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] || '8s').replace(/s$/, ''))
function modeEmpty(m) {
if (m.type === 'image') return !(m.resolutions || []).length && !m.image_to_image && !(m.ratios || []).length
return !(m.resolutions || []).length && !(m.durations || []).length && !m.max_reference_images && !m.id?.startsWith('seedance') && !(m.ratios || []).length
}
function priceOf(m) {
if (m.type === 'video') {
// Video charge = resolution price + duration price; show the combined range.
@@ -59,7 +63,8 @@ const videoParams = [
['prompt', 'string', '必填', '文字描述'],
['seconds', 'string|int', '必填', '时长秒数,如 "5" "8"(取决于模型支持)'],
['size', 'string', '可选', '如 "1280x720" / "720x1280" → 决定比例与分辨率'],
['input_reference', 'file', '可选', '首帧/参考图(multipart 文件;runway 图生视频必填 1 张)'],
['input_reference', 'file', '可选', '参考图/首尾帧(multipart 文件上传;多张时重复 input_reference[] 字段)。runway 图生视频必填 1 张,张数上限见上方模型表'],
['reference_mode', 'string', '可选', '参考图用途:"frame"=首尾帧(最多 2 张,第 1 张为首帧、第 2 张为尾帧,只传 1 张即仅首帧),"asset"=普通参考图。留空用模型默认;仅支持单一模式的模型不可传'],
]
// ---- size → 比例 × 分辨率档 对照表(用 size 该传的值)----
@@ -224,8 +229,8 @@ async function copy(text) {
<template>
<div class="theme-text space-y-10">
<header>
<div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">开发者</div>
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">接口文档</h1>
<span class="sticker"> 开发者</span>
<h1 class="mt-3 text-4xl md:text-5xl font-bold tracking-tight">接口文档</h1>
<p class="text-white/45 mt-2">完全兼容 OpenAI 接口规范 改个 <code class="text-white/70">base_url</code> <code class="text-white/70">api_key</code> 即可直接调用图像 / 视频 / 图生图全支持</p>
</header>
@@ -269,18 +274,35 @@ async function copy(text) {
<tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-3 font-medium">model</th>
<th class="px-4 py-3 font-medium">类型</th>
<th class="px-4 py-3 font-medium">分辨率 / 时长</th>
<th class="px-4 py-3 font-medium text-right">价格</th>
<th class="px-4 py-3 font-medium">能力</th>
</tr>
</thead>
<tbody>
<tr v-for="m in models" :key="m.id" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-3 font-mono text-white/90">{{ pubName(m) }}</td>
<td class="px-4 py-3 text-white/60">{{ m.type === 'video' ? '视频' : '图像' }}</td>
<td class="px-4 py-3 text-white/60">{{ (m.type === 'video' ? m.durations : m.resolutions || [])?.join(' · ') || '—' }}</td>
<td class="px-4 py-3 text-right tabular-nums text-white/80">{{ priceOf(m) }}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap items-center gap-1 text-[11px]">
<template v-if="m.type === 'image'">
<span v-for="r in (m.resolutions || [])" :key="r" class="cap cap-k">{{ r }}</span>
</template>
<template v-if="m.type === 'video'">
<span v-for="r in (m.resolutions || [])" :key="r" class="cap cap-k">{{ r }}</span>
<span v-if="(m.durations || []).length >= 2" class="cap cap-dur">{{ m.durations[0] }}{{ m.durations[m.durations.length - 1] }}</span>
<span v-else-if="(m.durations || []).length === 1" class="cap cap-dur">{{ m.durations[0] }}</span>
</template>
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cap cap-frame">首尾帧 {{ Math.min(m.max_reference_images, 2) }}</span>
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap cap-ref">参考图 {{ m.max_reference_images }}</span>
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap cap-ref">参考图 {{ m.max_reference_images }}</span>
<span v-if="m.type === 'image' && m.image_to_image && (!m.reference_mode || m.reference_mode === 'none')" class="cap cap-ref">参考图 1</span>
<span v-if="m.id?.startsWith('seedance')" class="cap cap-media">视频 3</span>
<span v-if="m.id?.startsWith('seedance')" class="cap cap-media">音频 3</span>
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap cap-ratio">{{ r.replace(':', '×') }}</span>
<span v-if="modeEmpty(m)" class="text-white/30"></span>
</div>
</td>
</tr>
<tr v-if="!models.length"><td colspan="4" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
<tr v-if="!models.length"><td colspan="3" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
</tbody>
</table>
</div>
@@ -408,14 +430,15 @@ async function copy(text) {
<!-- examples -->
<section class="space-y-4">
<h2 class="text-lg font-semibold">调用示例</h2>
<div v-for="ex in examples" :key="ex.title" class="card overflow-hidden">
<div class="flex items-center justify-between px-4 py-2.5 border-b border-white/[0.06]">
<span class="text-xs text-white/55">{{ ex.title }}</span>
<button @click="copy(ex.code)" class="text-xs text-white/50 hover:text-white inline-flex items-center gap-1.5 transition-colors">
<div v-for="ex in examples" :key="ex.title" class="code-card">
<div class="code-card-bar">
<span class="code-dots" aria-hidden="true"><i class="dot dot-r"></i><i class="dot dot-y"></i><i class="dot dot-g"></i></span>
<span class="code-card-title">{{ ex.title }}</span>
<button @click="copy(ex.code)" class="code-copy">
<Icon name="copy" class="w-3.5 h-3.5" /> 复制
</button>
</div>
<pre class="p-4 text-[12px] leading-relaxed text-white/80 overflow-auto"><code>{{ ex.code }}</code></pre>
<pre class="code-card-body"><code>{{ ex.code }}</code></pre>
</div>
</section>
@@ -460,4 +483,57 @@ async function copy(text) {
html.dark .badge-get { background: rgb(16 185 129 / 0.15); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.3); }
html.dark .badge-post { background: rgb(14 165 233 / 0.15); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.3); }
html.dark .badge-err { background: rgb(244 63 94 / 0.15); color: rgb(253 164 175); box-shadow: inset 0 0 0 1px rgb(251 113 133 / 0.3); }
.cap { display: inline-flex; align-items: center; padding: 0.18rem 0.55rem; font-size: 0.7rem; border-radius: 9999px; white-space: nowrap; }
.cap-k { background: rgb(16 185 129 / 0.12); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.3); }
.cap-dur { background: rgb(99 102 241 / 0.12); color: rgb(55 48 163); box-shadow: inset 0 0 0 1px rgb(99 102 241 / 0.3); }
.cap-frame { background: rgb(236 72 153 / 0.12); color: rgb(159 18 57); box-shadow: inset 0 0 0 1px rgb(236 72 153 / 0.3); }
.cap-ref { background: rgb(16 185 129 / 0.12); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.3); }
.cap-media { background: rgb(245 158 11 / 0.12); color: rgb(146 64 14); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.3); }
.cap-ratio { background: rgb(100 116 139 / 0.1); color: rgb(51 65 85); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.25); }
html.dark .cap-k { background: rgb(16 185 129 / 0.18); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.4); }
html.dark .cap-dur { background: rgb(99 102 241 / 0.18); color: rgb(165 180 252); box-shadow: inset 0 0 0 1px rgb(129 140 248 / 0.4); }
html.dark .cap-frame { background: rgb(236 72 153 / 0.18); color: rgb(244 114 182); box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.45); }
html.dark .cap-ref { background: rgb(16 185 129 / 0.18); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.4); }
html.dark .cap-media { background: rgb(245 158 11 / 0.18); color: rgb(252 211 77); box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45); }
html.dark .cap-ratio { background: rgb(255 255 255 / 0.06); color: rgb(255 255 255 / 0.65); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12); }
/* 苹果风代码卡片:自带深色配色,不随页面主题切换,深/浅背景下都可读 */
.code-card {
border-radius: 14px; overflow: hidden;
background: #1d1d1f;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.25), 0 8px 24px rgb(0 0 0 / 0.18), inset 0 0 0 1px rgb(255 255 255 / 0.08);
}
.code-card-bar {
display: flex; align-items: center; gap: 10px;
padding: 9px 12px;
background: linear-gradient(180deg, #333336, #2a2a2d);
border-bottom: 1px solid rgb(0 0 0 / 0.4);
}
.code-dots { display: inline-flex; gap: 6px; }
.code-dots .dot { width: 11px; height: 11px; border-radius: 9999px; display: inline-block; }
.dot-r { background: #ff5f57; box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 0.15); }
.dot-y { background: #febc2e; box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 0.15); }
.dot-g { background: #28c840; box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 0.15); }
.code-card-title {
flex: 1; min-width: 0; text-align: center;
font-size: 12px; color: rgb(255 255 255 / 0.55);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.code-copy {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; line-height: 1;
color: rgb(255 255 255 / 0.6);
background: rgb(255 255 255 / 0.08);
border-radius: 7px; padding: 5px 9px;
transition: background 0.15s, color 0.15s;
}
.code-copy:hover { color: #fff; background: rgb(255 255 255 / 0.16); }
.code-card-body {
margin: 0; padding: 14px 16px;
font-size: 12px; line-height: 1.7;
color: #e8e8ed;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
</style>
+563 -264
View File
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
import { api, generatedUrl } from '../api'
import { site } from '../site'
import { isAuthed, openLogin } from '../auth'
import Icon from '../components/Icon.vue'
import Mascot from '../components/Mascot.vue'
const router = useRouter()
@@ -13,12 +13,12 @@ const router = useRouter()
function go(path, query) {
const target = query ? { path, query } : { path }
if (isAuthed()) { router.push(target); return }
openLogin(typeof target === 'string' ? target : router.resolve(target).fullPath)
openLogin(router.resolve(target).fullPath)
}
const stats = ref({ generated_count: 0, recent: [] })
const managed = ref([]) // managed model records (provider, type, ...)
const showcase = ref({ hero: [], bento: [] })
const managed = ref([]) // managed model records (type, resolutions, ...)
const showcase = ref({ hero: [], bento: [], work: [] })
// heroDeck holds the top-3 hero cards in a RANDOMIZED order, so a different card
// fronts the deck on each page load. It's reshuffled only when the hero set first
// loads or its members change — not on every 30s poll, so the deck stays put.
@@ -35,8 +35,8 @@ let timer = null
async function refresh() {
try {
// No /logs here — that endpoint now requires a login and only returns the
// caller's own entries. The avg-latency KPI comes from /stats (aggregate,
// No /logs here — that endpoint requires a login and only returns the
// caller's own entries. The latency KPI comes from /stats (aggregate,
// prompt-free) so the public home page exposes nothing per-user.
const [s, m, sc] = await Promise.all([
api('/stats'),
@@ -45,10 +45,7 @@ async function refresh() {
])
stats.value = s.data || { generated_count: 0, recent: [] }
managed.value = (m.data?.data || []).filter((x) => x.enabled !== false)
showcase.value = sc.data?.data || { hero: [], bento: [] }
// Randomize which hero card fronts the deck: shuffle the top-3 once when the
// set first loads / changes; keep the order stable across refreshes so the
// deck doesn't reshuffle every 30s while the visitor is looking at it.
showcase.value = sc.data?.data || { hero: [], bento: [], work: [] }
const top3 = (showcase.value.hero || []).slice(0, 3)
const sameSet = heroDeck.value.length === top3.length
&& top3.every((c) => heroDeck.value.some((d) => d.id === c.id))
@@ -58,55 +55,127 @@ async function refresh() {
onMounted(() => { refresh(); timer = setInterval(refresh, 30000) })
onUnmounted(() => clearInterval(timer))
// ---- KPI strip — three signals derived from real data ----
// ---- showcase groups: all three kinds are used ----
const bento = computed(() => showcase.value.bento || [])
const works = computed(() => showcase.value.work || [])
// hero + bento imagery, flattened into one strip for the scrolling gallery
// marquee under the hero. Duplicated inline in the template so the loop reads
// seamlessly when it wraps around.
const gallery = computed(() => {
const seen = new Set()
const out = []
for (const c of [...(showcase.value.hero || []), ...bento.value]) {
if (c && !seen.has(c.id)) { seen.add(c.id); out.push(c) }
}
return out
})
// ---- KPI strip — real signals only ----
const imageModels = computed(() => managed.value.filter((m) => m.type !== 'video'))
const videoModels = computed(() => managed.value.filter((m) => m.type === 'video'))
const modelCount = computed(() => managed.value.length)
// Resolution range across image models, e.g. "1K4K" (or a single tier).
const RES_ORDER = ['1K', '2K', '4K']
const resRange = computed(() => {
const seen = new Set()
for (const m of imageModels.value) for (const r of m.resolutions || []) seen.add(r)
const tiers = RES_ORDER.filter((r) => seen.has(r))
if (!tiers.length) return '—'
return tiers.length === 1 ? tiers[0] : `${tiers[0]}${tiers[tiers.length - 1]}`
})
// Show the 24h average (matches the admin overview); fall back to the all-time
// average on a quiet day so the KPI isn't blank.
const avgElapsed = computed(() => stats.value?.avg_elapsed_ms_24h ?? stats.value?.avg_elapsed_ms ?? null)
const avgLabel = computed(() => {
if (avgElapsed.value == null) return '—'
if (avgElapsed.value < 1000) return avgElapsed.value + 'ms'
return (avgElapsed.value / 1000).toFixed(1) + 's'
return Math.round(avgElapsed.value / 1000) + 's'
})
// ---- “已接入” provider strip, derived from managed models ----
const PROVIDER_TINT = {
adobe: 'from-rose-400 to-orange-400',
chatgpt: 'from-violet-500 to-fuchsia-500',
google: 'from-sky-400 to-indigo-500',
default: 'from-emerald-400 to-teal-500',
}
const providerGroups = computed(() => {
const map = new Map()
for (const m of managed.value) {
const key = (m.provider || 'unknown').toLowerCase()
const g = map.get(key) || { name: m.provider || 'unknown', image: 0, video: 0 }
if (m.type === 'video') g.video++
else g.image++
map.set(key, g)
// ---- capability cards, derived from the enabled models ----
function refs(m) { return m.image_to_image === true || Number(m.max_reference_images || 0) > 0 }
const durationRange = computed(() => {
const secs = []
for (const m of videoModels.value) {
for (const d of m.durations || []) {
const n = parseInt(String(d), 10)
if (Number.isFinite(n)) secs.push(n)
}
return [...map.entries()].map(([key, g]) => ({
...g,
grad: PROVIDER_TINT[key] || PROVIDER_TINT.default,
})).sort((a, b) => (b.image + b.video) - (a.image + a.video))
}
if (!secs.length) return ''
const lo = Math.min(...secs), hi = Math.max(...secs)
return lo === hi ? `${lo}s` : `${lo}${hi}s`
})
const videoRes = computed(() => {
const seen = new Set()
for (const m of videoModels.value) for (const r of m.resolutions || []) seen.add(r)
return [...seen].sort().join(' / ')
})
// Each card deep-links into the workbench with ?model=<id> (the only intent
// PlaygroundView reads besides ?prompt), picking the first model that actually
// offers that capability — a card with no model behind it is dropped.
const caps = computed(() => {
const i2iModels = imageModels.value.filter(refs)
const i2vModels = videoModels.value.filter(refs)
return [
{
key: 't2i', title: '文字生图', model: imageModels.value[0]?.id,
desc: '一句话出图,画幅与分辨率按所选模型的可用范围给出。',
tint: 'rgb(52 211 153 / 0.14)', color: 'rgb(52 211 153)',
metas: [resRange.value, `${imageModels.value.length} 个模型`].filter(Boolean),
},
{
key: 'i2i', title: '参考图生图', model: i2iModels[0]?.id,
desc: '上传参考图换风格、改构图、做变体,支持多张参考。',
tint: 'rgb(217 70 239 / 0.14)', color: 'rgb(232 121 249)',
metas: ['多图参考', `${i2iModels.length} 个模型`],
},
{
key: 't2v', title: '文字生视频', model: videoModels.value[0]?.id,
desc: '一段描述生成短片,时长与分辨率按模型能力可选。',
tint: 'rgb(14 165 233 / 0.14)', color: 'rgb(56 189 248)',
metas: [durationRange.value, videoRes.value, `${videoModels.value.length} 个模型`].filter(Boolean),
},
{
key: 'i2v', title: '图生视频', model: i2vModels[0]?.id,
desc: '给参考图或首帧,让静态画面动起来。',
tint: 'rgb(245 158 11 / 0.14)', color: 'rgb(251 191 36)',
metas: ['参考图', `${i2vModels.length} 个模型`],
},
].filter((c) => c.model)
})
// Admin-curated "我们的作品" entries from /admin/api/showcase (kind=work),
// already sorted by weight on the server.
const works = computed(() => showcase.value.work || [])
// Hero deck = a random ordering of the top-3 hero entries (see refresh).
const bento = computed(() => showcase.value.bento || [])
// ---- model cards: the admin-set alias wins, so no upstream naming leaks ----
const MODEL_TINT = ['#a78bfa', '#f0abfc', '#fb7185', '#fbbf24', '#38bdf8', '#34d399', '#94a3b8']
const modelCards = computed(() => managed.value.map((m, i) => {
const bits = []
if (m.resolutions?.length) bits.push(m.resolutions.join(' / '))
if (m.type === 'video' && m.durations?.length) {
const lo = parseInt(m.durations[0], 10)
const hi = parseInt(m.durations[m.durations.length - 1], 10)
bits.push(lo === hi ? `${lo}s` : `${lo}${hi}s`)
}
if (m.type !== 'video') bits.push(refs(m) ? '支持参考图' : '文字生图')
return {
id: m.id,
name: m.alias || m.name || m.id,
spec: bits.join(' · '),
video: m.type === 'video',
glow: MODEL_TINT[i % MODEL_TINT.length],
}
}))
// Resolve an image reference: external URLs pass through, relative paths
// (like "user/abc.png") are served from /generated by the backend.
// (like "user/abc.png") are served from /images by the backend.
function imgSrc(image) {
if (!image) return ''
return /^https?:\/\//i.test(image) ? image : generatedUrl(image)
}
// Background style for a showcase card. Prefers a real image (the new shape);
// falls back to the legacy CSS gradient so seed entries still render.
// background-image (not <img>) keeps Edge's 视觉搜索 overlay off the artwork.
function cardBg(card) {
if (card.image) {
return {
@@ -118,287 +187,517 @@ function cardBg(card) {
return { background: card.gradient }
}
const prompt = ref('')
function submitPrompt() {
const p = prompt.value.trim()
go('/user', p ? { prompt: p } : null)
}
function useExample(ex) {
go('/user', { prompt: ex.prompt })
go('/user', ex.prompt ? { prompt: ex.prompt } : null)
}
</script>
<template>
<div class="space-y-28">
<!-- ============ HERO ============ -->
<section class="relative pt-8 md:pt-16 grid lg:grid-cols-[1.15fr_1fr] gap-10 lg:gap-16 items-center min-h-[640px]">
<!-- LEFT: copy -->
<div>
<h1 class="font-bold tracking-tight leading-[0.9] text-[clamp(2.5rem,6.5vw,6.5rem)] text-[color:var(--fg)]">
<span class="block text-[color:var(--fg-3)] font-light italic">Imagine</span>
<span class="block">it,
<span class="bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent italic">type</span>
it,
<div class="px-4 md:px-8">
<!-- ============ HERO (centered, mascot-forward) ============ -->
<section class="hero-band relative overflow-hidden pt-16 md:pt-24 pb-10">
<div class="anime-stars"></div>
<div class="relative flex flex-col items-center text-center">
<!-- mascot centerpiece with soft aura -->
<div class="relative mb-6">
<span class="mascot-aura"></span>
<Mascot :size="184" class="relative" />
</div>
<span class="sticker">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_rgb(52_211_153)]"></span>
{{ modelCount }} 个模型在线 · 图像与视频一站生成
</span>
<span class="block">own it.</span>
<h1 class="mt-5 font-extrabold tracking-tight leading-[1.05] text-[clamp(2.6rem,6vw,4.6rem)] text-[color:var(--fg)]">
把想象 <span class="anime-grad-text">说给 AI </span>
</h1>
<p class="mt-8 text-base md:text-lg text-[color:var(--fg-2)] max-w-md leading-relaxed">
{{ site.subtitle || '把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。' }}
<p class="mt-5 text-[15px] md:text-base text-[color:var(--fg-2)] max-w-xl leading-relaxed">
写一句话剩下的交给我们自动选模型自动排队图像与视频都在同一个工作台里出片
</p>
<div class="mt-10 flex items-center gap-4">
<button @click="go('/user')"
class="group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all">
开始画图
<span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform">
<!-- prompt composer the shortest path into the workbench -->
<form class="composer mt-8 !max-w-2xl w-full" @submit.prevent="submitPrompt">
<input v-model="prompt" class="composer-input" type="text"
placeholder="描述你想要的画面,比如:雨夜的东京小巷,霓虹倒映在湿滑路面…" />
<button type="submit" class="composer-go"> 生成</button>
</form>
<!-- quick starters come from the curated bento set -->
<div v-if="bento.length" class="mt-4 flex flex-wrap justify-center gap-2 max-w-2xl">
<button v-for="ex in bento.slice(0, 5)" :key="ex.id" type="button" class="chip"
@click="useExample(ex)">{{ ex.title }}</button>
</div>
</div>
</section>
<!-- ============ GALLERY MARQUEE (hero + bento imagery) ============ -->
<section v-if="gallery.length" class="mt-2 -mx-4 md:-mx-8 overflow-hidden marquee-mask">
<div class="marquee flex gap-4 px-4 md:px-8 w-max">
<button v-for="(card, i) in [...gallery, ...gallery]" :key="card.id + '-' + i" type="button"
class="gcard" :style="cardBg(card)" :aria-label="card.title" @click="useExample(card)">
<span class="tile-veil"></span>
<span class="tile-cap">
<i>{{ card.subtitle }}</i>
<b>{{ card.title }}</b>
</span>
</button>
<a href="#bento" class="text-sm text-[color:var(--fg-3)] hover:text-[color:var(--fg)] transition-colors">浏览灵感 </a>
</div>
<!-- counter strip three real signals: models we support, total
outputs ever generated, average wall-clock to produce one. -->
<div class="mt-14 grid grid-cols-3 gap-px rounded-2xl overflow-hidden ring-1 ring-[color:var(--hairline)] max-w-xl" style="background: var(--hairline)">
<div class="bg-[var(--surface)] px-5 py-4">
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ modelCount }}</div>
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已接入模型</div>
</div>
<div class="bg-[var(--surface)] px-5 py-4">
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ stats.generated_count || 0 }}</div>
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已生成作品</div>
</div>
<div class="bg-[var(--surface)] px-5 py-4">
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ avgLabel }}</div>
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">平均出片</div>
</div>
</div>
</div>
<!-- RIGHT: stacked card deck driven by /admin/api/showcase (kind=hero),
top 3 by weight. Position classes are picked by index so existing CSS
transforms in <style> still apply. -->
<div class="relative h-[480px] lg:h-[560px] hero-deck">
<template v-for="(card, i) in heroDeck" :key="card.id">
<!-- back -->
<div v-if="i === 2"
class="deck-card deck-card-3 absolute inset-y-8 right-12 lg:right-20 w-[68%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl"
:style="cardBg(card)">
<div class="absolute inset-0 mix-blend-overlay opacity-25"
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%222%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
<div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div>
<div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div>
</div>
</div>
<!-- middle -->
<div v-if="i === 1"
class="deck-card deck-card-2 absolute inset-y-4 right-4 lg:right-8 w-[72%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl"
:style="cardBg(card)">
<div class="absolute inset-0 mix-blend-overlay opacity-25"
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%223%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
<div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div>
<div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div>
</div>
</div>
<!-- front -->
<div v-if="i === 0"
class="deck-card deck-card-1 absolute inset-y-0 right-0 w-[78%] rounded-3xl overflow-hidden ring-1 ring-white/15 shadow-[0_30px_80px_-20px_rgba(168,85,247,0.45)]"
:style="cardBg(card)">
<div class="absolute inset-0 mix-blend-overlay opacity-30"
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
<div class="absolute top-0 inset-x-0 flex items-center justify-between px-5 py-4">
<div class="flex items-center gap-1.5">
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
</div>
<div class="text-[10px] uppercase tracking-[0.25em] text-white/60 font-mono">live</div>
</div>
<div class="absolute inset-x-0 bottom-0 p-6 bg-gradient-to-t from-black/90 via-black/40 to-transparent">
<div class="text-[10px] uppercase tracking-[0.3em] text-white/65">{{ card.subtitle }}</div>
<div class="text-2xl font-bold text-white mt-2">{{ card.title }}</div>
<p class="text-xs text-white/70 mt-2 line-clamp-2 leading-relaxed">{{ card.prompt }}</p>
</div>
</div>
</template>
</div>
</section>
<!-- ============ WORKS STRIP ============ -->
<section v-if="works.length" class="-mx-8 md:-mx-14">
<div class="px-8 md:px-14 mb-6">
<h2 class="text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">我们的作品</h2>
</div>
<div class="marquee-wrap">
<div class="marquee-track">
<div v-for="(w, i) in [...works, ...works]" :key="w.id + '-' + i"
class="shrink-0 w-56 h-56 rounded-2xl overflow-hidden ring-1 ring-white/[0.08] hover:ring-white/30 hover:scale-[1.02] transition-all cursor-pointer relative"
@click="go('/logs')">
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
<div :style="{ backgroundImage: `url(${imgSrc(w.image)})` }"
class="w-full h-full bg-cover bg-center"></div>
<div v-if="w.title" class="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
<div class="text-xs font-medium text-white line-clamp-1">{{ w.title }}</div>
</div>
</div>
<!-- ============ KPI STRIP ============ -->
<section class="mt-12 grid grid-cols-2 md:grid-cols-4 gap-3">
<div v-for="k in [
{ v: modelCount, l: '在线模型', e: '🧩' },
{ v: caps.length, l: '创作方式', e: '🎨' },
{ v: resRange, l: '输出分辨率', e: '🖼️' },
{ v: avgLabel, l: '平均出片 · 近 24h', e: '⚡' },
]" :key="k.l"
class="anime-card px-5 py-4">
<div class="flex items-center gap-2">
<span class="text-lg">{{ k.e }}</span>
<span class="text-[26px] font-bold tabular-nums text-[color:var(--fg)]">{{ k.v }}</span>
</div>
<div class="mt-1.5 text-[10px] uppercase tracking-[0.18em] text-[color:var(--fg-3)]">{{ k.l }}</div>
</div>
</section>
<!-- ============ BENTO EXAMPLES ============ -->
<section id="bento">
<div class="flex items-end justify-between flex-wrap gap-3 mb-8">
<div>
<div class="text-[10px] uppercase tracking-[0.3em] text-violet-300/70 font-medium">灵感</div>
<h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">从一个起点开始</h2>
<p class="text-[color:var(--fg-3)] mt-2 max-w-md">点任意一张,自动进入画图工作台并预填提示词</p>
</div>
</div>
<!-- ============ CAPABILITIES ============ -->
<section class="mt-24">
<div class="anime-eyebrow text-violet-500 dark:text-violet-300/80">能力</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">
{{ caps.length }} 种方式开始创作
</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
分辨率与时长直接读取模型配置,页面上写的就是你实际能选的。
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 grid-flow-row-dense gap-4 auto-rows-[200px]">
<button v-for="ex in bento" :key="ex.id" @click="useExample(ex)"
class="group relative text-left overflow-hidden rounded-3xl ring-1 ring-white/[0.06] hover:ring-white/20 transition-all"
:class="ex.span"
:style="cardBg(ex)">
<!-- grain -->
<div class="absolute inset-0 mix-blend-overlay opacity-30"
style="background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
<!-- veil -->
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div>
<div class="absolute top-5 right-5 w-9 h-9 rounded-xl bg-white/15 backdrop-blur ring-1 ring-white/20 grid place-items-center opacity-0 group-hover:opacity-100 group-hover:scale-100 scale-95 transition-all">
<Icon name="open" class="w-3.5 h-3.5" />
</div>
<div class="absolute inset-x-0 bottom-0 p-5 md:p-6">
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ ex.subtitle }}</div>
<div class="text-xl md:text-2xl font-bold mt-2 text-white">{{ ex.title }}</div>
<div class="text-xs text-white/65 mt-2 line-clamp-2 leading-relaxed">{{ ex.prompt }}</div>
</div>
<div class="mt-8 grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
<button v-for="c in caps" :key="c.key" type="button" class="cap-card"
@click="go('/user', { model: c.model })">
<span class="cap-arrow">↗</span>
<span class="cap-icon" :style="{ background: c.tint, color: c.color }">
<svg v-if="c.key === 't2i'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5h16v14H4z"/><path d="M4 15l4.5-4.5 3 3L15 10l5 5"/><circle cx="9" cy="9" r="1.3"/></svg>
<svg v-else-if="c.key === 'i2i'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 7h11v11H3z"/><path d="M10 4h11v11"/></svg>
<svg v-else-if="c.key === 't2v'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h13v12H3z"/><path d="M16 10l5-3v10l-5-3"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5h16v14H4z"/><path d="M9 9l6 3-6 3z"/></svg>
</span>
<span class="block text-[15.5px] font-bold text-[color:var(--fg)]">{{ c.title }}</span>
<span class="mt-2 block text-[12.5px] leading-relaxed text-[color:var(--fg-3)]">{{ c.desc }}</span>
<span class="mt-3.5 flex flex-wrap gap-1.5">
<i v-for="mt in c.metas" :key="mt" class="cap-meta">{{ mt }}</i>
</span>
</button>
</div>
</section>
<!-- ============ MODELS GRID ============ -->
<section>
<div class="flex items-end justify-between flex-wrap gap-3 mb-8">
<div>
<div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">模型</div>
<h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">已接入</h2>
<p class="text-[color:var(--fg-3)] mt-2 max-w-md">由管理员在后台注册;以下为已对接的上游 family</p>
</div>
</div>
<!-- ============ INSPIRATION (kind=bento) ============ -->
<section v-if="bento.length" id="bento" class="mt-24">
<div class="anime-eyebrow text-fuchsia-500 dark:text-fuchsia-300/80">灵感</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">点一下,套用同款提示词</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
任选一张,自动进入画图工作台并预填提示词。
</p>
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-3">
<div v-for="p in providerGroups" :key="p.name"
class="group relative overflow-hidden rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
<div class="absolute -right-8 -top-8 w-32 h-32 rounded-full bg-gradient-to-br opacity-30 blur-xl group-hover:opacity-50 transition-opacity"
:class="p.grad"></div>
<div class="relative flex items-center justify-between">
<div>
<div class="text-base font-semibold capitalize text-[color:var(--fg)]">{{ p.name }}</div>
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)] mt-1 flex gap-2">
<span v-if="p.image">{{ p.image }} 图像</span>
<span v-if="p.video">{{ p.video }} 视频</span>
<div class="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 grid-flow-row-dense gap-3 auto-rows-[170px]">
<button v-for="ex in bento" :key="ex.id" type="button" class="tile" :class="ex.span"
:style="cardBg(ex)" @click="useExample(ex)">
<span class="tile-veil"></span>
<span class="tile-cap">
<i>{{ ex.subtitle }}</i>
<b>{{ ex.title }}</b>
<em>{{ ex.prompt }}</em>
</span>
</button>
</div>
</section>
<!-- ============ WORKS (kind=work) ============ -->
<section v-if="works.length" class="mt-24">
<div class="anime-eyebrow text-sky-500 dark:text-sky-300/80">作品</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">大家都在生成什么</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
精选公开作品,点开即用同一条提示词继续创作。
</p>
<div class="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<button v-for="w in works" :key="w.id" type="button" class="work" @click="useExample(w)">
<span class="work-img" :style="cardBg(w)"></span>
<span class="work-overlay">
<b>{{ w.title }}</b>
<em>{{ w.prompt }}</em>
<i class="work-reuse">套用提示词</i>
</span>
</button>
</div>
<span class="w-2.5 h-2.5 rounded-full bg-gradient-to-br" :class="p.grad"></span>
</section>
<!-- ============ MODELS ============ -->
<section v-if="modelCards.length" class="mt-24">
<div class="anime-eyebrow text-emerald-500 dark:text-emerald-300/80">模型</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">在线模型</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
每个模型的分辨率、时长与参考图能力都写在卡片上,选之前就知道能做什么。
</p>
<div class="mt-8 flex gap-3 overflow-x-auto pb-2 model-row">
<div v-for="m in modelCards" :key="m.id" class="model-card">
<span class="model-glow" :style="{ background: m.glow }"></span>
<div class="relative text-[15px] font-bold text-[color:var(--fg)]">{{ m.name }}</div>
<div class="relative mt-1.5 text-[11px] text-[color:var(--fg-3)]">{{ m.spec }}</div>
<div class="relative mt-3.5">
<span :class="['model-tag', m.video ? 'is-video' : 'is-image']">{{ m.video ? '视频' : '图像' }}</span>
</div>
</div>
<div v-if="!providerGroups.length" class="text-sm text-[color:var(--fg-3)] col-span-full text-center py-10">
管理员尚未接入任何模型
</div>
</div>
</section>
<!-- ============ FOOTER CTA ============ -->
<section class="cta-band relative overflow-hidden rounded-[2rem] ring-1 ring-[color:var(--hairline)] p-12 md:p-20">
<div class="relative max-w-2xl">
<h3 class="text-4xl md:text-6xl font-bold tracking-tight leading-[1.05] text-[color:var(--fg)]">
停下来思考,<br />
<span class="italic font-light text-[color:var(--fg-3)]">不如</span> 直接开始
<!-- ============ CTA ============ -->
<section class="mt-24 cta-band relative overflow-hidden rounded-[2rem] ring-1 ring-[color:var(--hairline)] p-10 md:p-16">
<div class="relative max-w-xl">
<h3 class="text-[clamp(1.7rem,3.4vw,2.8rem)] font-extrabold tracking-tight leading-[1.2] text-[color:var(--fg)]">
与其继续想,<br />不如现在写一句。
</h3>
<p class="text-base md:text-lg text-[color:var(--fg-2)] mt-6 max-w-lg leading-relaxed">
登录即可开始打开画图工作台,写一句话, AI 替你完成
<p class="mt-4 text-[15px] text-[color:var(--fg-2)] max-w-md leading-relaxed">
注册即可开始,图像与视频都能试;不满意就换个模型,额度按次计费。
</p>
<button @click="go('/user')"
class="mt-10 group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all">
开始画图
<span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform">
</span>
<button type="button" @click="go('/user')"
class="mt-7 group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all">
免费开始
<span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform">→</span>
</button>
</div>
</section>
<!-- thin footer line -->
<footer class="pt-12 border-t border-[color:var(--hairline)] flex flex-wrap items-center justify-between gap-4 text-xs text-[color:var(--fg-3)]">
<footer class="mt-16 pt-10 border-t border-[color:var(--hairline)] flex flex-wrap items-center justify-between gap-4 text-xs text-[color:var(--fg-3)]">
<div class="flex items-center gap-3">
<span class="font-mono">{{ site.title }}</span>
<span class="font-semibold text-[color:var(--fg-2)]">{{ site.title }}</span>
<span>·</span>
<span>AI 生图与生视频平台</span>
</div>
<a href="https://github.com/cyi-cc/image2api" target="_blank" rel="noopener" class="font-mono hover:text-[color:var(--fg)] transition-colors">image2api</a>
<div class="flex items-center gap-4">
<router-link to="/docs" class="hover:text-[color:var(--fg)] transition-colors">文档</router-link>
<router-link to="/about" class="hover:text-[color:var(--fg)] transition-colors">关于</router-link>
</div>
</footer>
</div>
</template>
<style scoped>
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* ---------- composer ---------- */
.composer {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: 36rem;
padding: 0.5rem;
border-radius: 1.25rem;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline), 0 20px 50px -22px rgb(139 92 246 / 0.3);
transition: box-shadow 0.25s ease;
}
.composer:focus-within {
box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.55), 0 20px 60px -20px rgb(139 92 246 / 0.45);
}
.composer-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: var(--fg);
font-size: 15px;
padding: 0.7rem 0.85rem;
}
.composer-input::placeholder { color: var(--fg-faint); }
.composer-go {
flex: none;
border: none;
cursor: pointer;
color: #fff;
font-size: 14px;
font-weight: 600;
padding: 0.7rem 1.25rem;
border-radius: 0.9rem;
background: linear-gradient(135deg, #e879f9, #8b5cf6);
transition: transform 0.15s ease, filter 0.15s ease;
}
.composer-go:hover { transform: translateY(-1px); filter: brightness(1.1); }
/* ----- Hero stacked card deck ----- */
.hero-deck { perspective: 1200px; }
.deck-card { transform-origin: center bottom; will-change: transform, opacity; }
/* staggered fade-in-up on load, then a livelier continuous float with a gentle
rotation wobble. The :hover rules below still take over to fan the deck out. */
.deck-card-1 { animation: deckIn1 0.8s cubic-bezier(0.2,0.7,0.2,1) backwards, deckFloat1 7s ease-in-out 0.8s infinite; }
.deck-card-2 { animation: deckIn2 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.12s backwards, deckFloat2 9s ease-in-out 0.92s infinite; }
.deck-card-3 { animation: deckIn3 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.24s backwards, deckFloat3 11s ease-in-out 1.04s infinite; opacity: 0.85; }
@keyframes deckFloat1 {
0%,100% { transform: rotate(2deg) translateY(0); }
35% { transform: rotate(0.5deg) translateY(-16px); }
70% { transform: rotate(3deg) translateY(-7px); }
.chip {
font-size: 12.5px;
color: var(--fg-2);
cursor: pointer;
border: none;
padding: 0.42rem 0.8rem;
border-radius: 999px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: color 0.15s ease, box-shadow 0.15s ease;
}
@keyframes deckFloat2 {
0%,100% { transform: rotate(-3deg) translateY(0); }
50% { transform: rotate(-1.5deg) translateY(-13px); }
}
@keyframes deckFloat3 {
0%,100% { transform: rotate(5deg) translateY(0); }
50% { transform: rotate(3.5deg) translateY(-10px); }
}
@keyframes deckIn1 {
from { opacity: 0; transform: rotate(2deg) translateY(48px) scale(0.92); }
to { opacity: 1; transform: rotate(2deg) translateY(0) scale(1); }
}
@keyframes deckIn2 {
from { opacity: 0; transform: rotate(-3deg) translateY(48px) scale(0.92); }
to { opacity: 1; transform: rotate(-3deg) translateY(0) scale(1); }
}
@keyframes deckIn3 {
from { opacity: 0; transform: rotate(5deg) translateY(48px) scale(0.92); }
to { opacity: 0.85; transform: rotate(5deg) translateY(0) scale(1); }
}
.hero-deck:hover .deck-card-1 { transform: rotate(0deg) translateY(-12px); transition: transform 0.4s ease; animation: none; }
.hero-deck:hover .deck-card-2 { transform: rotate(-6deg) translate(-30px, 10px); transition: transform 0.4s ease; animation: none; }
.hero-deck:hover .deck-card-3 { transform: rotate(8deg) translate(-50px, 20px); transition: transform 0.4s ease; animation: none; opacity: 0.7; }
.chip:hover { color: var(--fg); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.5); }
.marquee-wrap {
/* ---------- hero cards (kind=hero) ---------- */
.fcard {
position: absolute;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.4rem;
overflow: hidden;
box-shadow: 0 30px 70px -24px rgb(0 0 0 / 0.55), inset 0 0 0 1px rgb(255 255 255 / 0.14);
transition: transform 0.45s cubic-bezier(0.2, 0.7, 0.2, 1);
animation: fcardIn 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) backwards;
}
.fcard-1 { width: 58%; height: 72%; top: 4%; right: 0; z-index: 3; transform: rotate(1.5deg); }
.fcard-2 { width: 46%; height: 44%; bottom: 2%; left: 4%; z-index: 2; transform: rotate(-2.5deg); animation-delay: 0.1s; }
.fcard-3 { width: 34%; height: 36%; top: 0; left: 0; z-index: 1; transform: rotate(4deg); opacity: 0.92; animation-delay: 0.2s; }
/* the back card is partly covered — keep only its title legible */
.fcard-3 .fcard-cap em { display: none; }
.fcard:hover { transform: translateY(-8px) rotate(0deg) scale(1.02); z-index: 4; }
@keyframes fcardIn {
from { opacity: 0; transform: translateY(28px) scale(0.95); }
}
.fcard-veil {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.82), transparent 58%);
}
.fcard-cap {
position: absolute;
left: 1rem;
right: 1rem;
bottom: 0.9rem;
text-align: left;
color: rgb(255 255 255 / 0.82);
}
.fcard-cap i { display: block; font-style: normal; font-size: 9.5px; letter-spacing: 0.18em; color: rgb(255 255 255 / 0.6); }
.fcard-cap b { display: block; font-size: 14px; margin-top: 0.2rem; color: #fff; }
.fcard-cap em {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-style: normal;
font-size: 11.5px;
line-height: 1.5;
margin-top: 0.25rem;
}
/* ---------- capability cards ---------- */
.cap-card {
position: relative;
overflow: hidden;
mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%);
text-align: left;
cursor: pointer;
border: none;
padding: 1.35rem;
border-radius: 1.25rem;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.marquee-track {
.cap-card:hover {
transform: translateY(-4px);
box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.45), 0 20px 40px -24px rgb(139 92 246 / 0.45);
}
.cap-arrow { position: absolute; top: 1.1rem; right: 1.1rem; color: var(--fg-3); transition: color 0.15s ease; }
.cap-card:hover .cap-arrow { color: var(--fg); }
.cap-icon {
width: 2.6rem;
height: 2.6rem;
border-radius: 0.85rem;
display: grid;
place-items: center;
margin-bottom: 0.9rem;
}
.cap-icon svg { width: 1.25rem; height: 1.25rem; }
.cap-meta {
font-style: normal;
font-size: 10px;
color: var(--fg-2);
padding: 0.18rem 0.5rem;
border-radius: 0.4rem;
background: var(--surface-2);
}
/* ---------- bento tiles (kind=bento) ---------- */
.tile {
position: relative;
overflow: hidden;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.25rem;
box-shadow: inset 0 0 0 1px var(--hairline);
transition: transform 0.35s ease;
}
.tile:hover { transform: translateY(-3px); }
.tile-veil {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.82) 0%, rgb(0 0 0 / 0.28) 45%, transparent 72%);
}
.tile-cap { position: absolute; left: 1rem; right: 1rem; bottom: 0.9rem; text-align: left; color: #fff; }
.tile-cap i { display: block; font-style: normal; font-size: 9.5px; letter-spacing: 0.2em; color: rgb(255 255 255 / 0.66); }
.tile-cap b { display: block; font-size: 15px; margin-top: 0.2rem; }
.tile-cap em {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-style: normal;
font-size: 11.5px;
line-height: 1.55;
margin-top: 0.3rem;
color: rgb(255 255 255 / 0.72);
}
/* ---------- works (kind=work) ---------- */
.work {
position: relative;
display: block;
width: 100%;
overflow: hidden;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.1rem;
box-shadow: inset 0 0 0 1px var(--hairline);
}
.work-img { display: block; width: 100%; aspect-ratio: 4 / 5; transition: transform 0.5s ease; }
.work:hover .work-img { transform: scale(1.04); }
.work-overlay {
position: absolute;
inset: 0;
display: flex;
gap: 1rem;
width: max-content;
padding: 0 2rem;
animation: marquee 55s linear infinite;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
text-align: left;
padding: 1rem;
background: linear-gradient(to top, rgb(0 0 0 / 0.86) 0%, rgb(0 0 0 / 0.3) 50%, transparent 78%);
opacity: 0;
transition: opacity 0.25s ease;
}
.marquee-wrap:hover .marquee-track { animation-play-state: paused; }
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
.work:hover .work-overlay, .work:focus-visible .work-overlay { opacity: 1; }
/* touch devices have no hover — keep the caption visible there */
@media (hover: none) { .work-overlay { opacity: 1; } }
.work-overlay b { color: #fff; font-size: 14px; }
.work-overlay em {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-style: normal;
font-size: 11.5px;
line-height: 1.6;
margin-top: 0.3rem;
color: rgb(255 255 255 / 0.8);
}
.work-reuse {
margin-top: 0.7rem;
font-style: normal;
font-size: 12px;
font-weight: 600;
color: rgb(11 13 19);
background: #fff;
border-radius: 999px;
padding: 0.4rem 0.8rem;
}
/* ---------- model row ---------- */
.model-row::-webkit-scrollbar { height: 6px; }
.model-row::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 3px; }
.model-card {
position: relative;
flex: none;
width: 14.5rem;
overflow: hidden;
padding: 1.25rem;
border-radius: 1.25rem;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: box-shadow 0.2s ease;
}
.model-card:hover { box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.42); }
.model-glow {
position: absolute;
top: -2.5rem;
right: -2.5rem;
width: 8rem;
height: 8rem;
border-radius: 50%;
filter: blur(40px);
opacity: 0.3;
}
.model-tag { font-size: 10.5px; padding: 0.18rem 0.55rem; border-radius: 0.4rem; }
.model-tag.is-image { background: rgb(52 211 153 / 0.14); color: rgb(52 211 153); }
.model-tag.is-video { background: rgb(125 211 252 / 0.14); color: rgb(56 189 248); }
/* ---------- hero band + mascot aura ---------- */
.hero-band {
border-radius: 2rem;
}
.mascot-aura {
position: absolute;
left: 50%;
top: 50%;
width: 260px;
height: 260px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: radial-gradient(circle, rgb(217 70 239 / 0.35), rgb(56 189 248 / 0.12) 55%, transparent 72%);
filter: blur(14px);
animation: auraPulse 5s ease-in-out infinite;
pointer-events: none;
}
@keyframes auraPulse {
0%, 100% { opacity: 0.8; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.08); }
}
/* ---------- gallery marquee ---------- */
.marquee-mask {
-webkit-mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
}
.marquee {
animation: marqueeScroll 42s linear infinite;
}
.marquee:hover { animation-play-state: paused; }
@keyframes marqueeScroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.gcard {
position: relative;
flex: none;
width: 15rem;
height: 10rem;
overflow: hidden;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.4rem;
background-size: cover;
background-position: center;
box-shadow: inset 0 0 0 1px var(--hairline);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.gcard:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.5), 0 22px 44px -26px rgb(168 85 247 / 0.6);
}
@media (prefers-reduced-motion: reduce) {
.marquee { animation: none; }
.mascot-aura { animation: none; }
}
</style>
+8 -3
View File
@@ -7,6 +7,7 @@ import { auth, refreshMe } from '../auth'
import { api } from '../api'
import { fmtIso } from '../utils/format'
import Icon from '../components/Icon.vue'
import Mascot from '../components/Mascot.vue'
// Reward per completed invite comes from the backend (credits.invite_reward),
// falling back to 3 until the response lands.
@@ -80,8 +81,8 @@ function toast(m) {
<div class="theme-text space-y-10">
<!-- header -->
<header>
<div class="text-[10px] uppercase tracking-[0.3em] text-amber-300/70 font-medium">奖励</div>
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">邀请好友</h1>
<span class="sticker">🎀 奖励</span>
<h1 class="mt-3 text-4xl md:text-5xl font-bold tracking-tight">邀请好友</h1>
<p class="text-white/45 mt-2">好友用你的链接注册,并完成首次生图后,你得 {{ INVITE_REWARD }} 积分</p>
</header>
@@ -131,7 +132,11 @@ function toast(m) {
<div class="card overflow-hidden">
<div v-if="loading && !records.length" class="text-center text-sm text-white/40 py-16">加载中</div>
<div v-else-if="!records.length" class="text-center text-sm text-white/40 py-16">还没有人通过你的链接注册</div>
<div v-else-if="!records.length" class="relative overflow-hidden flex flex-col items-center gap-3 text-white/40 py-14">
<div class="anime-stars"></div>
<Mascot :size="110" />
<span class="text-sm">还没有人通过你的链接注册快去分享吧</span>
</div>
<table v-else class="w-full text-sm">
<colgroup>
+48 -21
View File
@@ -21,6 +21,11 @@ const search = ref('')
const TYPE_LABEL = { image: '生图', video: '生视频' }
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
function capEmpty(m) {
if (m.type === 'image') return !(m.ratios || []).length && !m.image_to_image && (m.reference_mode === 'none' || !m.reference_mode)
return !(m.durations || []).length && !(m.resolutions || []).length && !m.max_reference_images && !m.id?.startsWith('seedance') && !(m.ratios || []).length
}
async function loadModels() {
loading.value = true
const r = await api('/managed-models')
@@ -196,7 +201,6 @@ onMounted(loadModels)
<span v-if="!(m.resolutions || []).length" class="text-white/30 text-xs"></span>
</div>
<div v-else class="flex flex-wrap gap-1">
<!-- video charge = resolution price + duration price; both show 普通/代理 -->
<span v-for="r in (m.resolutions || [])" :key="'r'+r" class="price-chip">
<span class="text-white/85">{{ r }}</span>
<span class="text-white/30 mx-1">普通</span>
@@ -204,33 +208,46 @@ onMounted(loadModels)
<span class="text-amber-300/40 ml-1.5">代理</span>
<span class="text-amber-300 tabular-nums">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</span>
</span>
<span v-for="d in (m.durations || [])" :key="'d'+d" class="price-chip">
<span v-if="m.duration_prices?.per_second != null" class="price-chip">
<span class="text-white/85">/s</span>
<span class="text-white/30 mx-1">普通</span>
<span class="text-sky-300 tabular-nums">{{ points(m.duration_prices.per_second) }}</span>
<span class="text-amber-300/40 ml-1.5">代理</span>
<span class="text-amber-300 tabular-nums">{{ points(m.duration_prices_agent?.per_second ?? m.duration_prices.per_second) }}</span>
</span>
<span v-else v-for="d in (m.durations || [])" :key="'d'+d" class="price-chip">
<span class="text-white/85">{{ d }}</span>
<span class="text-white/30 mx-1">普通</span>
<span class="text-sky-300 tabular-nums">+{{ points(m.duration_prices?.[d]) }}</span>
<span class="text-amber-300/40 ml-1.5">代理</span>
<span class="text-amber-300 tabular-nums">+{{ points(m.duration_prices_agent?.[d] ?? m.duration_prices?.[d]) }}</span>
</span>
<span v-if="!(m.resolutions || []).length && !(m.durations || []).length" class="text-white/30 text-xs"></span>
<span v-if="!(m.resolutions || []).length && !(m.durations || []).length && m.duration_prices?.per_second == null" class="text-white/30 text-xs"></span>
</div>
</td>
<!-- Capability extras that aren't a price: image-to-image,
video frame mode, supported resolutions for video. -->
<!-- Capability duration range, frame/ref modes, media support -->
<td class="px-3 py-3.5 align-middle">
<div class="flex flex-wrap items-center gap-1 text-[11px]">
<!-- reference capability with count: frame mode = 首尾帧, else 参考图 -->
<span v-if="m.max_reference_images > 0"
class="cap-chip cap-emerald"
:title="REF_MODE_LABEL[m.reference_mode]">{{ m.reference_mode === 'frame' ? '首尾帧' : '参考图' }} {{ m.max_reference_images }}</span>
<span v-else-if="m.type === 'image' && m.image_to_image"
class="cap-chip cap-emerald">参考图</span>
<span v-if="m.type === 'video'" v-for="r in (m.resolutions || [])" :key="'vr'+r"
class="cap-chip cap-slate">{{ r }}</span>
<span v-if="(m.type === 'image' && !(m.ratios || []).length && !m.image_to_image) ||
(m.type === 'video' && !(m.resolutions || []).length && !m.max_reference_images)"
class="text-white/30 text-xs"></span>
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap-chip cap-mono">{{ r }}</span>
<!-- Duration range (4s15s) -->
<span v-if="m.type === 'video' && (m.durations || []).length >= 2" class="cap-chip cap-dur">{{ m.durations[0] }}{{ m.durations[m.durations.length - 1] }}</span>
<span v-else-if="m.type === 'video' && (m.durations || []).length === 1" class="cap-chip cap-dur">{{ m.durations[0] }}</span>
<!-- Resolutions for image -->
<span v-if="m.type === 'image'" v-for="r in (m.resolutions || [])" :key="'ir'+r" class="cap-chip cap-slate">{{ r }}</span>
<!-- Resolutions for video -->
<span v-if="m.type === 'video'" v-for="r in (m.resolutions || [])" :key="'vr'+r" class="cap-chip cap-slate">{{ r }}</span>
<!-- Frame mode (首尾帧 2) -->
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cap-chip cap-frame">首尾帧 {{ Math.min(m.max_reference_images, 2) }}</span>
<!-- Reference image mode (参考图 9) -->
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap-chip cap-ref">参考图 {{ m.max_reference_images }}</span>
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap-chip cap-ref">参考图 {{ m.max_reference_images }}</span>
<span v-else-if="m.type === 'image' && m.image_to_image" class="cap-chip cap-ref">参考图</span>
<!-- Video/Audio for seedance -->
<span v-if="m.id?.startsWith('seedance')" class="cap-chip cap-media">视频 3</span>
<span v-if="m.id?.startsWith('seedance')" class="cap-chip cap-media">音频 3</span>
<!-- Ratios -->
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap-chip cap-mono">{{ r.replace(':', '×') }}</span>
<span v-if="capEmpty(m)" class="text-white/30 text-xs"></span>
</div>
</td>
@@ -326,15 +343,25 @@ onMounted(loadModels)
border-radius: 9999px;
white-space: nowrap;
}
.cap-emerald {
.cap-dur {
background: rgb(99 102 241 / 0.12);
color: rgb(165 180 252);
box-shadow: inset 0 0 0 1px rgb(129 140 248 / 0.3);
}
.cap-frame {
background: rgb(236 72 153 / 0.12);
color: rgb(244 114 182);
box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.35);
}
.cap-ref {
background: rgb(16 185 129 / 0.12);
color: rgb(110 231 183);
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.3);
box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.3);
}
.cap-amber {
.cap-media {
background: rgb(245 158 11 / 0.12);
color: rgb(252 211 77);
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.3);
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.35);
}
.cap-slate {
background: rgb(255 255 255 / 0.05);
+6 -4
View File
@@ -8,6 +8,7 @@ import { api, jsonBody } from '../api'
import { openPayment } from '../payment'
import { refreshMe } from '../auth'
import Icon from '../components/Icon.vue'
import Mascot from '../components/Mascot.vue'
const router = useRouter()
const items = ref([])
@@ -105,7 +106,7 @@ async function cont(o) {
<button v-for="s in [['','全部'],['pending','待支付'],['paid','已支付'],['cancelled','已取消']]" :key="s[0]"
@click="setStatus(s[0])"
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="status === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
:class="status === s[0] ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
</div>
<div class="flex-1 min-w-[180px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
@@ -115,9 +116,10 @@ async function cont(o) {
</div>
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中</div>
<div v-else-if="!total" class="card flex flex-col items-center gap-3 text-slate-400 py-24">
<span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="log" class="w-6 h-6" /></span>
<span class="text-sm">还没有充值订单</span>
<div v-else-if="!total" class="anime-card relative overflow-hidden flex flex-col items-center gap-3 text-[color:var(--fg-3)] py-20">
<div class="anime-stars"></div>
<Mascot :size="120" />
<span class="text-sm">还没有充值订单</span>
</div>
<div v-else class="card overflow-hidden !p-0">
+203 -27
View File
@@ -110,7 +110,9 @@ const maxRefs = computed(() => {
if (mode.value === 'video') {
const a = Number(familyPreset.value?.max_reference_images || 0)
const b = Number(model.value?.max_reference_images || 0)
return Math.max(a, b)
const raw = Math.max(a, b)
if (refMode.value === 'frame') return Math.min(raw, 2)
return raw
}
// Image-to-image: honor the model's configured max (gpt-image-2=3,
// seedream-4.5=6, flux-klein-2=4 ). Fall back to 1 when image_to_image is on
@@ -119,7 +121,27 @@ const maxRefs = computed(() => {
if (m > 0) return m
return model.value?.image_to_image ? 1 : 0
})
const refMode = computed(() => familyPreset.value?.reference_mode || model.value?.reference_mode || 'none')
const refModeDefault = computed(() => familyPreset.value?.reference_mode || model.value?.reference_mode || 'none')
const supportsBoth = computed(() => { const m = model.value; return m && m.reference_mode === 'style' && (m.max_reference_images || 0) >= 2 })
const refMode = ref(refModeDefault.value)
watch(refModeDefault, (v) => { refMode.value = v })
const isSeedanceModel = computed(() => /^seedance/.test(model.value?.id || ''))
const perSecondRate = computed(() => {
if (mode.value !== 'video' || !model.value) return null
const n = model.value.duration_prices?.per_second
if (n == null) return null
return isAgent.value ? (model.value.duration_prices_agent?.per_second ?? n) : n
})
const secsRange = computed(() => {
if (!perSecondRate.value) return null
const ds = (model.value.durations || []).map(parseFloat).filter(n => !isNaN(n))
if (ds.length >= 2) return { min: ds[0], max: ds[ds.length - 1] }
return { min: 4, max: 15 }
})
const secs = ref(secsRange.value?.min || 5)
watch(secsRange, (r) => { if (r && (secs.value < r.min || secs.value > r.max)) secs.value = r.min })
// Most video models (veo31, luma) support pure text2video, so refs are optional.
// A model can opt into strict image-to-video by declaring `requires_reference`
// in its preset (e.g. runway-gen4-turbo) then a first-frame image is mandatory.
@@ -150,6 +172,7 @@ const price = computed(() => {
const m = model.value
if (mode.value === 'video') {
const rp = tierPrice(m.prices, m.prices_agent, resolution.value)
if (perSecondRate.value) return rp + perSecondRate.value * secs.value
const dp = tierPrice(m.duration_prices, m.duration_prices_agent, duration.value)
if (rp == null || dp == null) return null
return rp + dp
@@ -185,6 +208,7 @@ function selectModel(id) {
function setMode(m) {
if (mode.value === m) return
mode.value = m
clearRefs() // /
// pick a default model of the new kind, if any
const first = allModels.value.find((x) => x.enabled !== false && x.type === m)
modelId.value = first?.id || ''
@@ -196,32 +220,73 @@ function openPicker() { fileInput.value && fileInput.value.click() }
// here at pick time so an oversized image fails fast with a clear message instead
// of charging + failing upstream after the upload.
const MAX_REF_BYTES = 20 * 1024 * 1024
// seedance
const MAX_VIDEOS = 3
const MAX_AUDIOS = 3
// seedance //
const fileAccept = computed(() => {
if (isSeedanceModel.value && refMode.value !== 'frame' && mode.value === 'video')
return 'image/*,video/*,audio/*'
return 'image/*'
})
//
const refCounts = computed(() => {
const imgs = refImages.value.filter(f => !f.fileType || f.fileType === 'image').length
const vids = refImages.value.filter(f => f.fileType === 'video').length
const auds = refImages.value.filter(f => f.fileType === 'audio').length
return { imgs, vids, auds }
})
function onFiles(ev) {
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 + 20MB cap. The preview shows a small downscaled thumbnail;
// the ORIGINAL file is kept untouched and is what gets uploaded at submit time.
// Shared by the file picker AND drag-and-drop. Honors per-model max + per-type
// limits (image/video/audio) + 20MB cap.
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 allowMedia = isSeedanceModel.value && refMode.value !== 'frame' && mode.value === 'video'
const tooBig = []
let added = 0
for (const f of files) {
if (added >= room) break
if (!f || !f.type) continue
const isImage = f.type.startsWith('image/')
const isVideo = f.type.startsWith('video/')
const isAudio = f.type.startsWith('audio/')
if (!isImage && !(allowMedia && (isVideo || isAudio))) continue
if (refImages.value.length >= maxRefs.value) break
if (f.size > MAX_REF_BYTES) { tooBig.push(f.name); continue }
const { imgs, vids, auds } = refCounts.value
if (isVideo && vids >= MAX_VIDEOS) continue
if (isAudio && auds >= MAX_AUDIOS) continue
const fileType = isVideo ? 'video' : isAudio ? 'audio' : 'image'
if (isImage) {
makeThumb(f).then((thumb) => {
if (thumb) { refImages.value.push({ name: f.name, file: f, thumb }); return }
// thumbnail failed (odd format) fall back to the full data URL preview
if (thumb) { refImages.value.push({ name: f.name, file: f, thumb, fileType: 'image' }); return }
const reader = new FileReader()
reader.onload = () => refImages.value.push({ name: f.name, file: f, dataUrl: reader.result })
reader.onload = () => refImages.value.push({ name: f.name, file: f, dataUrl: reader.result, fileType: 'image' })
reader.readAsDataURL(f)
})
added++
} else if (isVideo) {
// +
const mediaUrl = URL.createObjectURL(f)
makeVideoThumb(mediaUrl).then(({ thumb, duration }) => {
refImages.value.push({ name: f.name, file: f, fileType, thumb, duration, mediaUrl })
})
} else {
// objectURL metadata
const mediaUrl = URL.createObjectURL(f)
const a = new Audio()
a.preload = 'metadata'
a.onloadedmetadata = () => {
refImages.value.push({ name: f.name, file: f, fileType, mediaUrl, duration: a.duration })
}
a.onerror = () => refImages.value.push({ name: f.name, file: f, fileType, mediaUrl })
a.src = mediaUrl
}
}
error.value = tooBig.length
? `图片超过 20MB 已跳过:${tooBig.join('、')}(请压缩后再传)`
? `文件超过 20MB 已跳过:${tooBig.join('、')}(请压缩后再传)`
: ''
}
// Downscale an image blob/file to a small display-only thumbnail so the DOM
@@ -275,7 +340,66 @@ function onDragLeave(ev) {
if (ev.currentTarget.contains(ev.relatedTarget)) return
dragOver.value = false
}
function removeRef(i) { refImages.value.splice(i, 1) }
function clearRefs() {
stopAudio()
for (const r of refImages.value) if (r.mediaUrl) URL.revokeObjectURL(r.mediaUrl)
refImages.value = []
}
function removeRef(r) {
const i = refImages.value.indexOf(r)
if (i < 0) return
if (r.mediaUrl) URL.revokeObjectURL(r.mediaUrl)
if (r === playingAudio.value) stopAudio()
refImages.value.splice(i, 1)
}
// /
const tileRefs = computed(() => refImages.value.filter((r) => r.fileType !== 'audio'))
const audioRefs = computed(() => refImages.value.filter((r) => r.fileType === 'audio'))
// mm:ss
function fmtDur(s) {
if (!isFinite(s) || s <= 0) return ''
const n = Math.round(s)
return `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}`
}
// <audio>
const playingAudio = ref(null)
const audioEl = new Audio()
audioEl.onended = () => { playingAudio.value = null }
function stopAudio() {
audioEl.pause()
playingAudio.value = null
}
function toggleAudio(r) {
if (playingAudio.value === r) { stopAudio(); return }
audioEl.src = r.mediaUrl
audioEl.play().catch(() => {})
playingAudio.value = r
}
// objectURL canvas taint
function makeVideoThumb(url, maxSide = 320) {
return new Promise((resolve) => {
const v = document.createElement('video')
v.muted = true
v.preload = 'auto'
v.src = url
const done = (thumb) => resolve({ thumb, duration: v.duration })
v.addEventListener('loadeddata', () => {
try {
const scale = Math.min(1, maxSide / Math.max(v.videoWidth, v.videoHeight))
const c = document.createElement('canvas')
c.width = Math.max(1, Math.round(v.videoWidth * scale))
c.height = Math.max(1, Math.round(v.videoHeight * scale))
c.getContext('2d').drawImage(v, 0, 0, c.width, c.height)
done(c.toDataURL('image/jpeg', 0.8))
} catch { done('') }
})
v.addEventListener('error', () => done(''))
})
}
// Re-hydrate reference thumbnails from server URLs (after a reload). Fetches
// each /images URL (same-origin, cookie-authed) and converts to a data URL so
@@ -402,7 +526,7 @@ async function fireOne() {
prompt: prompt.value,
ratio: ratio.value,
resolution: resolution.value,
duration: mode.value === 'video' ? duration.value : '',
duration: mode.value === 'video' ? (perSecondRate.value ? secs.value + 's' : duration.value) : '',
deai: mode.value === 'image' && deaiEnabled.value ? deai.value : false,
status: 'pending',
url: '',
@@ -425,7 +549,7 @@ async function fireOne() {
const payload = {
model: task.model, prompt: task.prompt, ratio: task.ratio, resolution: task.resolution,
}
if (task.kind === 'video') payload.duration = task.duration
if (task.kind === 'video') { payload.duration = perSecondRate.value ? secs.value + 's' : task.duration; if (refMode.value !== refModeDefault.value) payload.reference_mode = refMode.value }
if (task.kind === 'image' && task.deai) payload.deai = true
if (refsSnapshot.length) {
const refs = await Promise.all(refsSnapshot.map(refToBase64))
@@ -629,6 +753,8 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('keydown', onKey)
clearInterval(pollTimer)
stopAudio()
for (const r of refImages.value) if (r.mediaUrl) URL.revokeObjectURL(r.mediaUrl)
})
</script>
@@ -680,7 +806,7 @@ onUnmounted(() => {
<div class="flex flex-wrap gap-1.5">
<button v-for="r in ratios" :key="r" type="button" @click="ratio = r"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="ratio === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
:class="ratio === r ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ r }}
</button>
</div>
@@ -691,7 +817,7 @@ onUnmounted(() => {
<div class="flex flex-wrap gap-1.5">
<button v-for="r in resolutions" :key="r" type="button" @click="resolution = r"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="resolution === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
:class="resolution === r ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ r }}
</button>
</div>
@@ -711,12 +837,19 @@ onUnmounted(() => {
</button>
</div>
<div v-if="mode === 'video' && durations.length > 0">
<!-- per-second slider -->
<div v-if="perSecondRate && secsRange">
<label class="block text-xs font-medium text-slate-500 mb-1.5">时长 <span class="text-slate-400 font-normal tabular-nums">{{ secs }}s</span></label>
<input type="range" v-model.number="secs" :min="secsRange.min" :max="secsRange.max" step="1"
class="w-full accent-indigo-500 h-2 rounded-lg appearance-none bg-slate-200 cursor-pointer" />
<div class="flex justify-between text-[10px] text-slate-400 mt-0.5"><span>{{ secsRange.min }}s</span><span>{{ secsRange.max }}s</span></div>
</div>
<div v-if="mode === 'video' && durations.length > 0 && !perSecondRate">
<label class="block text-xs font-medium text-slate-500 mb-1.5">时长</label>
<div class="flex flex-wrap gap-1.5">
<button v-for="d in durations" :key="d" type="button" @click="duration = d"
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 ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ d }}
</button>
</div>
@@ -727,17 +860,37 @@ onUnmounted(() => {
<label class="block text-xs font-medium text-slate-500 mb-1.5">
{{ refMode === 'frame' && mode === 'video' ? '首尾帧' : '参考图' }}
<span class="text-slate-400 font-normal">
(最多 {{ maxRefs }} {{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 20MB)
(最多 {{ maxRefs }} {{ isSeedanceModel && refMode !== 'frame' && mode === 'video' ? ` · 图片${refCounts.imgs}/${maxRefs} · 视频${refCounts.vids}/${MAX_VIDEOS} · 音频${refCounts.auds}/${MAX_AUDIOS}` : '' }}{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 文件 20MB)
</span>
<span v-if="refsRequired" class="text-rose-500">*</span>
</label>
<!-- 参考图 / 首尾帧 分段切换 仅两种模式都支持时显示单独一行 -->
<div v-if="supportsBoth" class="inline-flex p-0.5 mb-2 rounded-lg bg-slate-100 gap-0.5">
<button v-for="opt in [{ v: 'asset', label: '参考图' }, { v: 'frame', label: '首尾帧' }]" :key="opt.v"
type="button" @click="if ((refMode === 'frame' ? 'frame' : 'asset') !== opt.v) { refMode = opt.v; clearRefs() }"
class="rounded-md px-3 py-1 text-[11px] font-medium transition-colors"
:class="(refMode === 'frame' ? 'frame' : 'asset') === opt.v ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
{{ opt.label }}
</button>
</div>
<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 tileRefs" :key="img.mediaUrl || img.url || img.name + i"
class="relative w-20 h-20 rounded-lg overflow-hidden border border-slate-200 bg-slate-50 transition-all">
<img :src="img.thumb || img.dataUrl || img.url" class="w-full h-full object-cover" />
<button type="button" @click="removeRef(i)"
<!-- 图片预览 -->
<img v-if="!img.fileType || img.fileType === 'image'" :src="img.thumb || img.dataUrl || img.url" class="w-full h-full object-cover" />
<!-- 视频预览首帧缩略图 + "视频"角标无缩略图时退回图标 -->
<template v-else-if="img.fileType === 'video'">
<img v-if="img.thumb" :src="img.thumb" class="w-full h-full object-cover" />
<div v-else class="w-full h-full grid place-items-center bg-slate-800 text-white">
<Icon name="video" class="w-6 h-6 opacity-80" />
</div>
<span class="absolute top-1 left-1 rounded bg-slate-900/70 text-white text-[9px] px-1 py-0.5">
视频
</span>
</template>
<button type="button" @click="removeRef(img)"
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-slate-900/70 text-white hover:bg-rose-500 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed">
<Icon name="close" class="w-3 h-3" />
</button>
@@ -752,7 +905,25 @@ onUnmounted(() => {
<Icon :name="dragOver ? 'download' : 'plus'" class="w-5 h-5" />
</button>
</div>
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="onFiles" />
<!-- 音频单独一行可播放条 -->
<div v-if="audioRefs.length" class="mt-2 space-y-1.5">
<div v-for="a in audioRefs" :key="a.mediaUrl"
class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5">
<button type="button" @click="toggleAudio(a)" :title="playingAudio === a ? '暂停' : '播放'"
class="w-7 h-7 shrink-0 rounded-full grid place-items-center transition-colors"
:class="playingAudio === a ? 'chip-on' : 'bg-slate-200 text-slate-600 hover:bg-slate-300'">
<Icon :name="playingAudio === a ? 'pause' : 'play'" class="w-3 h-3" />
</button>
<Icon name="music" class="w-3.5 h-3.5 shrink-0 text-slate-400" />
<span class="flex-1 min-w-0 truncate text-xs text-slate-600">{{ a.name }}</span>
<span v-if="fmtDur(a.duration)" class="text-[10px] tabular-nums text-slate-400 shrink-0">{{ fmtDur(a.duration) }}</span>
<button type="button" @click="removeRef(a)" title="移除"
class="w-5 h-5 shrink-0 rounded-full text-slate-400 hover:text-rose-500 grid place-items-center">
<Icon name="close" class="w-3 h-3" />
</button>
</div>
</div>
<input ref="fileInput" type="file" :accept="fileAccept" multiple class="hidden" @change="onFiles" />
</div>
<!-- 生图张数 14 (image only) each is a separate concurrent generation. -->
@@ -761,7 +932,7 @@ onUnmounted(() => {
<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'">
:class="count === n ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
{{ n }}
</button>
</div>
@@ -808,6 +979,11 @@ onUnmounted(() => {
:style="{ backgroundImage: `url(${item.url}.thumb.jpg)` }"
class="absolute inset-0 w-full h-full bg-cover bg-center cursor-zoom-in transition-transform duration-300 group-hover:scale-105"></div>
<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>
<!-- 类型角标视频卡标"视频"图片卡不加 -->
<span v-if="item.kind === 'video'"
class="absolute top-2 left-2 rounded-md bg-black/55 ring-1 ring-white/10 text-white text-[10px] px-1.5 py-0.5 pointer-events-none">
视频
</span>
<!-- hover action: 上参考图. Image use as reference; video 末帧设为首帧
(only shown when the model supports 首尾帧). Clicking the media zooms. -->
<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+12 -5
View File
@@ -54,11 +54,16 @@ async function generateKey() {
toast('新 Key 已生成 — 请立刻复制保存(只显示这一次)')
}
// Only the just-minted plaintext is copyable the server-side preview is a mask
// (abcd), so copying it would hand out a key that can never authenticate.
async function copyKey() {
if (!hasKey.value) return
if (!apiKey.value) {
toast('完整 Key 仅生成时显示一次,已无法找回 — 请重新生成')
return
}
try {
await navigator.clipboard.writeText(apiKey.value || keyPreview.value)
toast(apiKey.value ? '已复制完整 Key' : '完整 Key 仅生成时显示一次,这里只能复制预览')
await navigator.clipboard.writeText(apiKey.value)
toast('已复制完整 Key')
} catch { toast('复制失败') }
}
@@ -276,7 +281,7 @@ async function recharge() {
</div>
<h2 class="text-xl font-bold mt-4">API Key</h2>
<p class="text-sm text-white/50 mt-2 leading-relaxed">
调用 <code class="bg-white/10 text-white/90 px-1 py-0.5 rounded text-xs">/v1/*</code> 接口需要的访问密钥会保存在浏览器本地
调用 <code class="bg-white/10 text-white/90 px-1 py-0.5 rounded text-xs">/v1/*</code> 接口需要的访问密钥完整密钥不保存仅生成时显示一次
</p>
</div>
<div>
@@ -290,7 +295,9 @@ async function recharge() {
{{ apiKeyRevealed ? '隐藏' : '显示' }}
</button>
<button @click="copyKey"
class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 hover:bg-white/[0.06] hover:ring-white/20 transition-all">
:title="apiKey ? '复制完整 Key' : '完整 Key 仅生成时显示一次,已无法找回'"
class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 transition-all hover:bg-white/[0.06] hover:ring-white/20"
:class="!apiKey && 'opacity-40'">
复制
</button>
</div>
+7 -5
View File
@@ -11,6 +11,7 @@ import { copyText } from '../utils/clipboard'
import { points } from '../credits'
import Icon from '../components/Icon.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
import Mascot from '../components/Mascot.vue'
const router = useRouter()
const items = ref([]) // current server page
@@ -163,14 +164,14 @@ const params = (e) => {
<button v-for="s in [['','全部'],['success','成功'],['failed','失败'],['pending','进行中']]" :key="s[0]"
@click="setStatus(s[0])"
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="statusFilter === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
:class="statusFilter === s[0] ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
</div>
<div class="w-px h-5 bg-slate-200"></div>
<div class="flex items-center gap-1.5">
<button v-for="s in [['','全部来源'],['web','画图台'],['api','API']]" :key="s[0]"
@click="setSource(s[0])"
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="sourceFilter === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
:class="sourceFilter === s[0] ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
</div>
<div class="flex-1 min-w-[180px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
@@ -181,9 +182,10 @@ const params = (e) => {
<!-- States -->
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中</div>
<div v-else-if="!total" class="card flex flex-col items-center gap-3 text-slate-400 py-24">
<span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="log" class="w-6 h-6" /></span>
<span class="text-sm">还没有生成日志</span>
<div v-else-if="!total" class="anime-card relative overflow-hidden flex flex-col items-center gap-3 text-[color:var(--fg-3)] py-20">
<div class="anime-stars"></div>
<Mascot :size="120" />
<span class="text-sm">还没有生成日志</span>
</div>
<!-- Table -->
+10 -8
View File
@@ -7,6 +7,7 @@ import { copyText } from '../utils/clipboard'
import { zipSync } from 'fflate'
import Icon from '../components/Icon.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
import Mascot from '../components/Mascot.vue'
const router = useRouter()
@@ -247,18 +248,18 @@ onUnmounted(() => {
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1.5">
<button @click="setKind('')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="kindFilter === '' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">全部</button>
:class="kindFilter === '' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">全部</button>
<button @click="setKind('image')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="kindFilter === 'image' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">图像</button>
:class="kindFilter === 'image' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">图像</button>
<button @click="setKind('video')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="kindFilter === 'video' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">视频</button>
:class="kindFilter === 'video' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">视频</button>
</div>
<div class="flex-1 min-w-[180px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
class="field !py-1.5 text-xs" placeholder="搜索提示词或模型…" />
</div>
<button @click="togglePickAll" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors inline-flex items-center gap-1"
:class="pageAllPicked ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
:class="pageAllPicked ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
<Icon name="check" class="w-3.5 h-3.5" /> 全选本页
</button>
<template v-if="picked.size">
@@ -274,10 +275,11 @@ onUnmounted(() => {
<!-- Empty -->
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中</div>
<div v-else-if="!filtered.length"
class="card flex flex-col items-center gap-3 text-slate-400 py-24">
<span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="spark" class="w-6 h-6" /></span>
<span class="text-sm">还没有创作记录</span>
<button @click="router.push('/user')" class="btn-soft mt-2">开始第一张</button>
class="anime-card relative overflow-hidden flex flex-col items-center gap-3 text-[color:var(--fg-3)] py-20">
<div class="anime-stars"></div>
<Mascot :size="120" />
<span class="text-sm">还没有创作记录画一张试试吧</span>
<button @click="router.push('/user')" class="bubble-btn mt-1">开始第一张 </button>
</div>
<!-- Cards gallery layout, matching 图片管理 (ImagesView) -->