fix(appearance): 本地保存站点 Logo 并支持登录视频自动播放 (#435)
This commit is contained in:
@@ -8,6 +8,8 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -29,7 +31,7 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
appearanceSchemaVersion = 6
|
||||
appearanceSchemaVersion = 7
|
||||
appearanceLogoMaxBytes int64 = 5 << 20
|
||||
appearancePosterMaxBytes int64 = 10 << 20
|
||||
appearanceVideoMaxBytes int64 = 256 << 20
|
||||
@@ -56,6 +58,7 @@ type AppearanceSetting struct {
|
||||
LogoFrameEnabled bool `json:"logoFrameEnabled"`
|
||||
AuthVideoResourceID string `json:"authVideoResourceId"`
|
||||
AuthVideoPosterResourceID string `json:"authVideoPosterResourceId"`
|
||||
AuthVideoAutoplay bool `json:"authVideoAutoplay"`
|
||||
SkinID string `json:"skinId"`
|
||||
SkinThemes []AppearanceSkinTheme `json:"skinThemes"`
|
||||
SEOTitle string `json:"seoTitle"`
|
||||
@@ -77,6 +80,7 @@ type PublicAppearanceSetting struct {
|
||||
LogoFrameEnabled bool `json:"logoFrameEnabled"`
|
||||
AuthVideoURL string `json:"authVideoUrl"`
|
||||
AuthVideoPosterURL string `json:"authVideoPosterUrl"`
|
||||
AuthVideoAutoplay bool `json:"authVideoAutoplay"`
|
||||
SkinID string `json:"skinId"`
|
||||
ActiveSkin AppearanceSkinTheme `json:"activeSkin"`
|
||||
SEOTitle string `json:"seoTitle"`
|
||||
@@ -105,13 +109,14 @@ type AdminAppearanceSetting struct {
|
||||
|
||||
func defaultAppearanceSetting() AppearanceSetting {
|
||||
return AppearanceSetting{
|
||||
SchemaVersion: appearanceSchemaVersion,
|
||||
BrandName: defaultAppearanceBrandName,
|
||||
BrandSlug: defaultAppearanceBrandSlug,
|
||||
AuthHeroTitle: defaultAppearanceHeroTitle,
|
||||
LogoFrameEnabled: true,
|
||||
SkinID: defaultAppearanceSkinID,
|
||||
SkinThemes: defaultAppearanceSkinThemes(),
|
||||
SchemaVersion: appearanceSchemaVersion,
|
||||
BrandName: defaultAppearanceBrandName,
|
||||
BrandSlug: defaultAppearanceBrandSlug,
|
||||
AuthHeroTitle: defaultAppearanceHeroTitle,
|
||||
AuthVideoAutoplay: true,
|
||||
LogoFrameEnabled: true,
|
||||
SkinID: defaultAppearanceSkinID,
|
||||
SkinThemes: defaultAppearanceSkinThemes(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +138,7 @@ func (s *Service) Appearance() (*PublicAppearanceSetting, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value = s.resolveAvailableAppearanceAssets(value)
|
||||
return publicAppearanceSetting(setting, value), nil
|
||||
}
|
||||
|
||||
@@ -144,6 +150,7 @@ func (s *Service) AdminAppearance(actor *model.User) (*AdminAppearanceSetting, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value = s.resolveAvailableAppearanceAssets(value)
|
||||
result := &AdminAppearanceSetting{
|
||||
AppearanceSetting: value,
|
||||
Public: *publicAppearanceSetting(setting, value),
|
||||
@@ -257,7 +264,15 @@ func (s *Service) UploadAppearanceAsset(actor *model.User, slot string, header *
|
||||
if slot == AppearanceAssetVideo {
|
||||
kind = "video"
|
||||
}
|
||||
resource, err := s.UploadResource(actor.ID, header, kind, 0, 0, 0)
|
||||
var resource *model.Resource
|
||||
// Logos are tiny, installation-owned assets. Keep them in the server's
|
||||
// persistent resource directory so their availability and cost do not
|
||||
// depend on the administrator's currently selected object storage.
|
||||
if slot == AppearanceAssetLogo || slot == AppearanceAssetDarkLogo {
|
||||
resource, err = s.uploadLocalResource(actor.ID, header, kind, 0, 0, 0)
|
||||
} else {
|
||||
resource, err = s.UploadResource(actor.ID, header, kind, 0, 0, 0)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -364,6 +379,42 @@ func (s *Service) readAppearance() (*model.SystemSetting, AppearanceSetting, err
|
||||
return setting, value, nil
|
||||
}
|
||||
|
||||
// resolveAvailableAppearanceAssets prevents stale resource references from
|
||||
// being advertised to anonymous clients or echoed back into the admin editor.
|
||||
// Remote objects are not probed on every appearance request; the frontend has
|
||||
// its own load-error fallback for objects that disappear outside the system.
|
||||
func (s *Service) resolveAvailableAppearanceAssets(value AppearanceSetting) AppearanceSetting {
|
||||
for _, slot := range []string{AppearanceAssetLogo, AppearanceAssetDarkLogo, AppearanceAssetVideo, AppearanceAssetPoster} {
|
||||
resourceID := appearanceResourceID(value, slot)
|
||||
if resourceID == "" || s.appearanceAssetAvailable(slot, resourceID) {
|
||||
continue
|
||||
}
|
||||
switch slot {
|
||||
case AppearanceAssetLogo:
|
||||
value.LogoResourceID = ""
|
||||
case AppearanceAssetDarkLogo:
|
||||
value.DarkLogoResourceID = ""
|
||||
case AppearanceAssetVideo:
|
||||
value.AuthVideoResourceID = ""
|
||||
case AppearanceAssetPoster:
|
||||
value.AuthVideoPosterResourceID = ""
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *Service) appearanceAssetAvailable(slot string, resourceID string) bool {
|
||||
resource, err := s.repo.Resource(resourceID)
|
||||
if err != nil || validateAppearanceResourceType(slot, resource) != nil {
|
||||
return false
|
||||
}
|
||||
if resource.Provider != "local" {
|
||||
return true
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)))
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func validateAppearanceSetting(value AppearanceSetting) error {
|
||||
if value.BrandName == "" || utf8.RuneCountInString(value.BrandName) > 40 {
|
||||
return BadAuthRequest("品牌名称必须为 1 到 40 个字符")
|
||||
@@ -571,6 +622,7 @@ func publicAppearanceSetting(setting *model.SystemSetting, value AppearanceSetti
|
||||
LogoFrameEnabled: value.LogoFrameEnabled,
|
||||
AuthVideoURL: defaultAppearanceVideoURL,
|
||||
AuthVideoPosterURL: defaultAppearancePosterURL,
|
||||
AuthVideoAutoplay: value.AuthVideoAutoplay,
|
||||
SkinID: value.SkinID,
|
||||
ActiveSkin: activeAppearanceSkin(value.SkinThemes, value.SkinID),
|
||||
SEOTitle: effectiveAppearanceSEOTitle(value),
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestAppearanceDefaultsPreserveBuiltInBrand(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appearance.Configured || appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != defaultAppearanceBrandName || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || appearance.AuthVideoURL != defaultAppearanceVideoURL || appearance.AuthVideoPosterURL != defaultAppearancePosterURL || appearance.SkinID != defaultAppearanceSkinID || appearance.SEOTitle != defaultAppearanceBrandName || !strings.Contains(appearance.SEODescription, defaultAppearanceBrandName) || !strings.Contains(appearance.FooterCopyright, defaultAppearanceBrandName) || appearance.ICPFilingEnabled {
|
||||
if appearance.Configured || appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != defaultAppearanceBrandName || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || appearance.AuthVideoURL != defaultAppearanceVideoURL || appearance.AuthVideoPosterURL != defaultAppearancePosterURL || !appearance.AuthVideoAutoplay || appearance.SkinID != defaultAppearanceSkinID || appearance.SEOTitle != defaultAppearanceBrandName || !strings.Contains(appearance.SEODescription, defaultAppearanceBrandName) || !strings.Contains(appearance.FooterCopyright, defaultAppearanceBrandName) || appearance.ICPFilingEnabled {
|
||||
t.Fatalf("Appearance() = %#v", appearance)
|
||||
}
|
||||
if appearance.LogoConfigured || appearance.DarkLogoConfigured || appearance.AuthVideoConfigured || appearance.AuthVideoPosterConfigured || appearance.Revision != "builtin" {
|
||||
@@ -96,7 +96,7 @@ func appearanceColorContrastRatio(t *testing.T, foreground, background string) f
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
func TestAppearanceBackfillsVersionSixFieldsForExistingSetting(t *testing.T) {
|
||||
func TestAppearanceBackfillsVersionSevenFieldsForExistingSetting(t *testing.T) {
|
||||
svc, db, _, _ := newAppearanceTestService(t)
|
||||
legacy := model.SystemSetting{Key: appearanceSettingKey, ValueJSON: `{"schemaVersion":1,"brandName":"旧品牌","skinId":"classic"}`}
|
||||
if err := db.Create(&legacy).Error; err != nil {
|
||||
@@ -107,13 +107,13 @@ func TestAppearanceBackfillsVersionSixFieldsForExistingSetting(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != "旧品牌" || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || appearance.SEOTitle != "旧品牌" || !strings.Contains(appearance.SEODescription, "旧品牌") || !strings.Contains(appearance.FooterCopyright, "旧品牌") || appearance.ActiveSkin.ID != "classic" {
|
||||
if appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != "旧品牌" || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || !appearance.AuthVideoAutoplay || appearance.SEOTitle != "旧品牌" || !strings.Contains(appearance.SEODescription, "旧品牌") || !strings.Contains(appearance.FooterCopyright, "旧品牌") || appearance.ActiveSkin.ID != "classic" {
|
||||
t.Fatalf("legacy appearance = %#v", appearance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T) {
|
||||
svc, db, _, admin := newAppearanceTestService(t)
|
||||
svc, db, dataDir, admin := newAppearanceTestService(t)
|
||||
resources := []model.Resource{
|
||||
{ID: "brand-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"},
|
||||
{ID: "brand-logo-dark", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo-dark.png", MimeType: "image/png"},
|
||||
@@ -123,6 +123,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T
|
||||
if err := db.Create(&resources).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeAppearanceResourceFixtures(t, dataDir, resources)
|
||||
|
||||
updated, err := svc.UpdateAppearance(admin, AppearanceSetting{
|
||||
BrandName: "HIMA Studio",
|
||||
@@ -134,6 +135,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T
|
||||
LogoFrameEnabled: false,
|
||||
AuthVideoResourceID: "brand-video",
|
||||
AuthVideoPosterResourceID: "brand-poster",
|
||||
AuthVideoAutoplay: false,
|
||||
SkinID: "studio-indigo",
|
||||
SEOTitle: "HIMA Studio - AI 影视工作台",
|
||||
SEODescription: "面向 Agent、图片、视频、画布与短剧生产的一体化 AI 创作工作台。",
|
||||
@@ -145,7 +147,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updated.Configured || updated.BrandName != "HIMA Studio" || updated.BrandSlug != "hima-studio" || updated.Public.BrandSlug != "hima-studio" || updated.AuthHeroTitle != "把灵感,\n变成可见的故事。" || updated.Public.AuthHeroDescription != "从画布开始,持续推进你的创作。" || !updated.Public.LogoConfigured || !updated.Public.DarkLogoConfigured || updated.Public.LogoFrameEnabled || !updated.Public.AuthVideoConfigured || !updated.Public.AuthVideoPosterConfigured || updated.Public.SkinID != "studio-indigo" || updated.Public.SEOTitle != "HIMA Studio - AI 影视工作台" || updated.Public.SEOKeywords != "AI 影视,短剧,画布" || !updated.Public.ICPFilingEnabled || updated.Public.ICPFilingNumber != "蜀ICP备2026000000号-1" {
|
||||
if !updated.Configured || updated.BrandName != "HIMA Studio" || updated.BrandSlug != "hima-studio" || updated.Public.BrandSlug != "hima-studio" || updated.AuthHeroTitle != "把灵感,\n变成可见的故事。" || updated.Public.AuthHeroDescription != "从画布开始,持续推进你的创作。" || !updated.Public.LogoConfigured || !updated.Public.DarkLogoConfigured || updated.Public.LogoFrameEnabled || !updated.Public.AuthVideoConfigured || !updated.Public.AuthVideoPosterConfigured || updated.Public.AuthVideoAutoplay || updated.Public.SkinID != "studio-indigo" || updated.Public.SEOTitle != "HIMA Studio - AI 影视工作台" || updated.Public.SEOKeywords != "AI 影视,短剧,画布" || !updated.Public.ICPFilingEnabled || updated.Public.ICPFilingNumber != "蜀ICP备2026000000号-1" {
|
||||
t.Fatalf("UpdateAppearance() = %#v", updated)
|
||||
}
|
||||
for _, assetURL := range []string{updated.Public.LogoURL, updated.Public.DarkLogoURL, updated.Public.AuthVideoURL, updated.Public.AuthVideoPosterURL} {
|
||||
@@ -173,7 +175,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T
|
||||
}
|
||||
|
||||
func TestAppearanceReusesSingleLogoAcrossThemes(t *testing.T) {
|
||||
svc, db, _, admin := newAppearanceTestService(t)
|
||||
svc, db, dataDir, admin := newAppearanceTestService(t)
|
||||
resources := []model.Resource{
|
||||
{ID: "only-light", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/light.png", MimeType: "image/png"},
|
||||
{ID: "only-dark", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/dark.png", MimeType: "image/png"},
|
||||
@@ -181,6 +183,7 @@ func TestAppearanceReusesSingleLogoAcrossThemes(t *testing.T) {
|
||||
if err := db.Create(&resources).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeAppearanceResourceFixtures(t, dataDir, resources)
|
||||
|
||||
lightOnly, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: "only-light", LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID})
|
||||
if err != nil {
|
||||
@@ -281,6 +284,89 @@ func TestOpenAppearanceAssetOnlyServesConfiguredSlot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppearanceFallsBackWhenConfiguredLogoResourceIsMissing(t *testing.T) {
|
||||
svc, db, _, admin := newAppearanceTestService(t)
|
||||
value := defaultAppearanceSetting()
|
||||
value.BrandName = "HIMA Studio"
|
||||
value.BrandSlug = "hima-studio"
|
||||
value.LogoResourceID = "missing-logo"
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
appearance, err := svc.Appearance()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appearance.LogoConfigured || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL {
|
||||
t.Fatalf("missing logo public appearance = %#v", appearance)
|
||||
}
|
||||
adminAppearance, err := svc.AdminAppearance(admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if adminAppearance.LogoResourceID != "" || adminAppearance.Public.LogoConfigured {
|
||||
t.Fatalf("missing logo admin appearance = %#v", adminAppearance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppearanceFallsBackWhenLocalLogoFileIsMissing(t *testing.T) {
|
||||
svc, db, _, _ := newAppearanceTestService(t)
|
||||
resource := model.Resource{ID: "missing-local-logo", UserID: "appearance-admin", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/missing.png", MimeType: "image/png"}
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := defaultAppearanceSetting()
|
||||
value.LogoResourceID = resource.ID
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
appearance, err := svc.Appearance()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appearance.LogoConfigured || appearance.LogoURL != defaultAppearanceLogoURL {
|
||||
t.Fatalf("missing local logo public appearance = %#v", appearance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAppearanceLogoAlwaysUsesServerLocalStorage(t *testing.T) {
|
||||
svc, db, dataDir, admin := newAppearanceTestService(t)
|
||||
requests := make(chan struct{}, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests <- struct{}{}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
seedOSSEnabled(t, db, admin.ID, server.URL)
|
||||
pngBytes := append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...)
|
||||
|
||||
for _, slot := range []string{AppearanceAssetLogo, AppearanceAssetDarkLogo} {
|
||||
resource, err := svc.UploadAppearanceAsset(admin, slot, multipartFileHeader(t, slot+".png", "image/png", pngBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("UploadAppearanceAsset(%s): %v", slot, err)
|
||||
}
|
||||
if resource.Provider != "local" || resource.Endpoint != "" || resource.Bucket != "" || resource.StorageSettingID != "" {
|
||||
t.Fatalf("UploadAppearanceAsset(%s) storage = %#v", slot, resource)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dataDir, "resources", filepath.FromSlash(resource.ObjectKey))); err != nil {
|
||||
t.Fatalf("UploadAppearanceAsset(%s) local file: %v", slot, err)
|
||||
}
|
||||
}
|
||||
if len(requests) != 0 {
|
||||
t.Fatalf("logo upload unexpectedly contacted object storage %d time(s)", len(requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAppearanceValidatesLoginCopy(t *testing.T) {
|
||||
svc, _, _, admin := newAppearanceTestService(t)
|
||||
|
||||
@@ -415,6 +501,22 @@ func multipartFileHeader(t *testing.T, name string, contentType string, data []b
|
||||
return req.MultipartForm.File["file"][0]
|
||||
}
|
||||
|
||||
func writeAppearanceResourceFixtures(t *testing.T, dataDir string, resources []model.Resource) {
|
||||
t.Helper()
|
||||
for _, resource := range resources {
|
||||
if resource.Provider != "local" {
|
||||
continue
|
||||
}
|
||||
filePath := filepath.Join(dataDir, "resources", filepath.FromSlash(resource.ObjectKey))
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte(resource.ID), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newAppearanceTestService(t *testing.T) (*Service, *gorm.DB, string, *model.User) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
|
||||
@@ -269,6 +269,17 @@ func validatePublicResourceBaseURL(raw string) (*url.URL, error) {
|
||||
}
|
||||
|
||||
func (s *Service) UploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) {
|
||||
return s.uploadResource(userID, header, kind, width, height, durationMs, false, uploadIdentity...)
|
||||
}
|
||||
|
||||
// uploadLocalResource keeps small system-owned assets on the server even when
|
||||
// the uploader or platform has enabled object storage. The resource still uses
|
||||
// the normal database record and persistent data directory lifecycle.
|
||||
func (s *Service) uploadLocalResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) {
|
||||
return s.uploadResource(userID, header, kind, width, height, durationMs, true, uploadIdentity...)
|
||||
}
|
||||
|
||||
func (s *Service) uploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, forceLocal bool, uploadIdentity ...string) (*model.Resource, error) {
|
||||
if header == nil {
|
||||
return nil, BadAuthRequest("请选择要上传的文件")
|
||||
}
|
||||
@@ -298,7 +309,7 @@ func (s *Service) UploadResource(userID string, header *multipart.FileHeader, ki
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resource, stored, err := s.storeResource(userID, kind, header.Filename, mimeType, header.Size, width, height, durationMs, file, uploadKey)
|
||||
resource, stored, err := s.storeResource(userID, kind, header.Filename, mimeType, header.Size, width, height, durationMs, file, uploadKey, forceLocal)
|
||||
if err != nil {
|
||||
s.releaseUserUploadQuota(userID, day, header.Size)
|
||||
} else if stored {
|
||||
@@ -334,7 +345,7 @@ func (s *Service) UploadResourceFile(userID string, fileName string, size int64,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resource, stored, err := s.storeResource(userID, kind, fileName, mimeType, size, width, height, durationMs, file, uploadKey)
|
||||
resource, stored, err := s.storeResource(userID, kind, fileName, mimeType, size, width, height, durationMs, file, uploadKey, false)
|
||||
if err != nil {
|
||||
s.releaseUserUploadQuota(userID, day, size)
|
||||
} else if stored {
|
||||
@@ -397,7 +408,7 @@ func (s *Service) ImportResourceURL(userID string, rawURL string, kind string, w
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resource, stored, err := s.storeResource(userID, kind, payload.fileName, payload.mimeType, size, width, height, durationMs, bytes.NewReader(payload.data), uploadKey)
|
||||
resource, stored, err := s.storeResource(userID, kind, payload.fileName, payload.mimeType, size, width, height, durationMs, bytes.NewReader(payload.data), uploadKey, false)
|
||||
if err != nil {
|
||||
s.releaseUserUploadQuota(userID, day, size)
|
||||
} else if stored {
|
||||
@@ -492,7 +503,7 @@ func (s *Service) openResourceRange(userID string, resource *model.Resource, ran
|
||||
return &ResourceStream{Resource: resource, Body: stream.body, StatusCode: stream.statusCode, ContentLength: stream.contentLength, ContentRange: stream.contentRange, AcceptRanges: stream.acceptRanges}, nil
|
||||
}
|
||||
|
||||
func (s *Service) storeResource(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string) (*model.Resource, bool, error) {
|
||||
func (s *Service) storeResource(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string, forceLocal bool) (*model.Resource, bool, error) {
|
||||
if existing, err := s.resourceForUploadKey(userID, uploadKey); err != nil {
|
||||
return nil, false, err
|
||||
} else if existing != nil {
|
||||
@@ -503,9 +514,15 @@ func (s *Service) storeResource(userID string, kind string, fileName string, mim
|
||||
}
|
||||
now := time.Now()
|
||||
kind = normalizeResourceKind(kind, mimeType)
|
||||
setting, storageSettingID, useOSS, err := s.activeResourceOSSSetting(userID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
var setting ossSettingValue
|
||||
var storageSettingID string
|
||||
var useOSS bool
|
||||
var err error
|
||||
if !forceLocal {
|
||||
setting, storageSettingID, useOSS, err = s.activeResourceOSSSetting(userID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
provider := "local"
|
||||
objectKey := localObjectKey(userID, kind, fileName, mimeType, now)
|
||||
@@ -752,7 +769,7 @@ func (s *Service) persistGeneratedMediaValueMode(userID string, value interface{
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
resource, _, err := s.storeResource(userID, kind, "generated."+extensionFromMimeType(mimeType), mimeType, int64(len(data)), width, height, int64(intValue(item["durationMs"])), bytes.NewReader(data), nil)
|
||||
resource, _, err := s.storeResource(userID, kind, "generated."+extensionFromMimeType(mimeType), mimeType, int64(len(data)), width, height, int64(intValue(item["durationMs"])), bytes.NewReader(data), nil, false)
|
||||
if err != nil {
|
||||
if enforceQuota {
|
||||
s.releaseUserUploadQuota(userID, quotaDay, int64(len(data)))
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestStoreResourceDegradesToLocalWhenOSSUnavailable(t *testing.T) {
|
||||
|
||||
resource, created, err := service.storeResource(
|
||||
"user-1", "video", "intro.mp4", "video/mp4", 1024,
|
||||
1920, 1080, 0, bytes.NewReader([]byte("fake-mp4-bytes")), nil,
|
||||
1920, 1080, 0, bytes.NewReader([]byte("fake-mp4-bytes")), nil, false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("storeResource: %v", err)
|
||||
@@ -74,7 +74,7 @@ func TestStoreResourceLocalPathUnaffectedByOSS(t *testing.T) {
|
||||
|
||||
resource, created, err := service.storeResource(
|
||||
"user-2", "video", "local.mp4", "video/mp4", 512,
|
||||
1280, 720, 0, bytes.NewReader([]byte("local-bytes")), nil,
|
||||
1280, 720, 0, bytes.NewReader([]byte("local-bytes")), nil, false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("storeResource: %v", err)
|
||||
|
||||
@@ -925,14 +925,14 @@ func newResourceTestService(t *testing.T) *Service {
|
||||
func TestStoreResourceReusesReadyUploadIdentity(t *testing.T) {
|
||||
svc := newResourceTestService(t)
|
||||
uploadKey := normalizedResourceUploadKey([]string{"image:user-1:logical-upload"})
|
||||
first, stored, err := svc.storeResource("user-1", "image", "first.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey)
|
||||
first, stored, err := svc.storeResource("user-1", "image", "first.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stored {
|
||||
t.Fatal("first upload was not stored")
|
||||
}
|
||||
second, stored, err := svc.storeResource("user-1", "image", "second.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey)
|
||||
second, stored, err := svc.storeResource("user-1", "image", "second.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func (w *taskWorkerCoordinator) processTimelineRender(task *model.Task, ctx cont
|
||||
}
|
||||
durationMs := planDurationMs(plan)
|
||||
fileName := fmt.Sprintf("timeline-render-%s.mp4", time.Now().Format("20060102-150405"))
|
||||
resource, _, err := s.storeResource(task.UserID, "media", fileName, "video/mp4", stat.Size(), renderWidth, renderHeight, durationMs, file, nil)
|
||||
resource, _, err := s.storeResource(task.UserID, "media", fileName, "video/mp4", stat.Size(), renderWidth, renderHeight, durationMs, file, nil, false)
|
||||
if err != nil || resource == nil {
|
||||
return w.failTimelineTask(task, "渲染失败", "保存渲染产物失败")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useState, type CSSProperties, type ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appearanceLogoURL, useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
@@ -14,8 +14,24 @@ type BrandLogoProps = {
|
||||
export function BrandLogo({ className, fallback, alt = "", theme = "auto" }: BrandLogoProps) {
|
||||
const appearance = useAppearanceStore((state) => state.appearance);
|
||||
const currentTheme = useThemeStore((state) => state.theme);
|
||||
const source = appearanceLogoURL(appearance, theme === "auto" ? currentTheme : theme);
|
||||
const [failedSource, setFailedSource] = useState<string | null>(null);
|
||||
if (!appearance.logoConfigured) return <>{fallback}</>;
|
||||
return <img src={appearanceLogoURL(appearance, theme === "auto" ? currentTheme : theme)} alt={alt} className={cn("block object-contain", className)} draggable={false} />;
|
||||
// A configured custom logo must never fall through to the built-in brand
|
||||
// when its file becomes unavailable. Keep its footprint neutral instead.
|
||||
if (failedSource === source) return <span className={cn("block", className)} aria-hidden="true" />;
|
||||
return (
|
||||
<img
|
||||
src={source}
|
||||
alt={alt}
|
||||
className={cn("block object-contain", className)}
|
||||
draggable={false}
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.visibility = "hidden";
|
||||
setFailedSource(source);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrandLogoFrame({ className, logoClassName, fallback, alt = "", theme = "auto" }: BrandLogoProps & { logoClassName?: string }) {
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function AppearanceSettingsPage() {
|
||||
const [brandSlug, setBrandSlug] = useState("");
|
||||
const [authHeroTitle, setAuthHeroTitle] = useState("");
|
||||
const [authHeroDescription, setAuthHeroDescription] = useState("");
|
||||
const [authVideoAutoplay, setAuthVideoAutoplay] = useState(true);
|
||||
const [logoFrameEnabled, setLogoFrameEnabled] = useState(true);
|
||||
const [skinId, setSkinId] = useState("classic");
|
||||
const [skinThemes, setSkinThemes] = useState<SkinDefinition[]>([DEFAULT_CLASSIC_SKIN]);
|
||||
@@ -61,6 +62,7 @@ export default function AppearanceSettingsPage() {
|
||||
brandSlug.trim().toLocaleLowerCase() !== setting?.brandSlug ||
|
||||
normalizeDraftCopy(authHeroTitle) !== setting?.authHeroTitle ||
|
||||
normalizeDraftCopy(authHeroDescription) !== setting?.authHeroDescription ||
|
||||
authVideoAutoplay !== setting?.authVideoAutoplay ||
|
||||
logoFrameEnabled !== setting?.logoFrameEnabled ||
|
||||
skinId !== setting?.skinId ||
|
||||
JSON.stringify(skinThemes) !== JSON.stringify(setting?.skinThemes) ||
|
||||
@@ -80,6 +82,7 @@ export default function AppearanceSettingsPage() {
|
||||
setBrandSlug(value.brandSlug);
|
||||
setAuthHeroTitle(value.authHeroTitle);
|
||||
setAuthHeroDescription(value.authHeroDescription);
|
||||
setAuthVideoAutoplay(value.authVideoAutoplay);
|
||||
setLogoFrameEnabled(value.logoFrameEnabled);
|
||||
const themes = value.skinThemes.map((theme) => normalizeSkinDefinition(theme));
|
||||
setSkinThemes(themes.length ? themes : [cloneSkinDefinition(DEFAULT_CLASSIC_SKIN)]);
|
||||
@@ -189,6 +192,7 @@ export default function AppearanceSettingsPage() {
|
||||
setBrandSlug(setting.brandSlug);
|
||||
setAuthHeroTitle(setting.authHeroTitle);
|
||||
setAuthHeroDescription(setting.authHeroDescription);
|
||||
setAuthVideoAutoplay(setting.authVideoAutoplay);
|
||||
setLogoFrameEnabled(setting.logoFrameEnabled);
|
||||
setSkinId(setting.skinId);
|
||||
setSkinThemes(setting.skinThemes.map((theme) => cloneSkinDefinition(theme)));
|
||||
@@ -324,6 +328,7 @@ export default function AppearanceSettingsPage() {
|
||||
logoFrameEnabled,
|
||||
authVideoResourceId: ids.video,
|
||||
authVideoPosterResourceId: ids.poster,
|
||||
authVideoAutoplay,
|
||||
skinId,
|
||||
skinThemes,
|
||||
seoTitle: nextSeoTitle,
|
||||
@@ -547,6 +552,16 @@ export default function AppearanceSettingsPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="admin-appearance-media-list">
|
||||
<div className="admin-appearance-video-autoplay-option">
|
||||
<span className="admin-appearance-video-autoplay-copy">
|
||||
<strong>登录页视频自动播放</strong>
|
||||
<small>默认开启并静音循环播放;访客启用“减少动态效果”时仍尊重其系统偏好。</small>
|
||||
</span>
|
||||
<span className="admin-appearance-video-autoplay-control">
|
||||
<span>{authVideoAutoplay ? "已开启" : "已关闭"}</span>
|
||||
<Switch checked={authVideoAutoplay} disabled={saving || refreshing || restoring} aria-label="登录页视频自动播放" onChange={setAuthVideoAutoplay} />
|
||||
</span>
|
||||
</div>
|
||||
<AssetPicker
|
||||
slot="video"
|
||||
title="品牌视频"
|
||||
@@ -580,7 +595,7 @@ export default function AppearanceSettingsPage() {
|
||||
<AdminStatusBadge label={dirty ? "未保存" : "线上版本"} tone={dirty ? "warning" : "success"} />
|
||||
</div>
|
||||
<div className="admin-appearance-preview-stage">
|
||||
<video key={previews.video} src={previews.video} poster={previews.poster || undefined} muted loop playsInline autoPlay preload="metadata" />
|
||||
<video key={`${previews.video}-${authVideoAutoplay}`} src={previews.video} poster={previews.poster || undefined} muted loop playsInline autoPlay={authVideoAutoplay} preload="metadata" />
|
||||
<span className="admin-appearance-preview-shade" />
|
||||
<span className="admin-appearance-preview-brand">
|
||||
<img src={previews.logoDark} alt="" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { ConfigProvider, Tabs } from "antd";
|
||||
import { ArrowLeft, Play } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||
@@ -51,17 +51,33 @@ export function AuthScene() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [videoActive, setVideoActive] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [manualVideoActive, setManualVideoActive] = useState(false);
|
||||
const [videoPlaying, setVideoPlaying] = useState(false);
|
||||
const [failedPosterURL, setFailedPosterURL] = useState("");
|
||||
const recovery = location.pathname === "/forgot-password";
|
||||
const activeTab = location.pathname === "/register" ? "register" : "login";
|
||||
const copy = recovery ? authCopy.recovery : activeTab === "register" ? authCopy.register : authCopy.login;
|
||||
const automaticVideoActive = appearance.authVideoAutoplay && !reducedMotion;
|
||||
const videoActive = Boolean(appearance.authVideoUrl && (automaticVideoActive || manualVideoActive));
|
||||
|
||||
useEffect(() => {
|
||||
setManualVideoActive(false);
|
||||
setVideoPlaying(false);
|
||||
}, [appearance.authVideoUrl, appearance.authVideoAutoplay]);
|
||||
|
||||
const playVideo = () => {
|
||||
setManualVideoActive(true);
|
||||
requestAnimationFrame(() => {
|
||||
void videoRef.current?.play().catch(() => setVideoPlaying(false));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="auth-scene h-dvh min-h-0 overflow-y-auto text-white lg:overflow-hidden">
|
||||
<div className="grid min-h-full lg:h-full lg:grid-cols-[minmax(0,1.32fr)_minmax(520px,1fr)]">
|
||||
<section className="relative min-h-[250px] overflow-hidden sm:min-h-[320px] lg:min-h-0" aria-label={`${appearance.brandName}品牌影片`}>
|
||||
{videoActive && appearance.authVideoUrl ? <video className="absolute inset-0 size-full object-cover" src={appearance.authVideoUrl} poster={appearance.authVideoPosterUrl || undefined} autoPlay={!reducedMotion} muted loop playsInline preload="metadata" /> : appearance.authVideoPosterUrl && failedPosterURL !== appearance.authVideoPosterUrl ? <img className="absolute inset-0 size-full object-cover" src={appearance.authVideoPosterUrl} alt="" decoding="async" onError={() => setFailedPosterURL(appearance.authVideoPosterUrl)} /> : null}
|
||||
{videoActive && appearance.authVideoUrl ? <video ref={videoRef} className="absolute inset-0 size-full object-cover" src={appearance.authVideoUrl} poster={appearance.authVideoPosterUrl || undefined} autoPlay muted loop playsInline preload="metadata" onPlay={() => setVideoPlaying(true)} onPause={() => setVideoPlaying(false)} /> : appearance.authVideoPosterUrl && failedPosterURL !== appearance.authVideoPosterUrl ? <img className="absolute inset-0 size-full object-cover" src={appearance.authVideoPosterUrl} alt="" decoding="async" onError={() => setFailedPosterURL(appearance.authVideoPosterUrl)} /> : null}
|
||||
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(180deg,rgba(4,5,8,.58),transparent_42%,rgba(4,5,8,.74))]" />
|
||||
<div aria-hidden className="auth-scene-video-blend absolute inset-y-0 right-0 hidden w-[clamp(120px,14vw,240px)] lg:block" />
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-4 p-5 sm:p-7 lg:p-9">
|
||||
@@ -69,9 +85,9 @@ export function AuthScene() {
|
||||
<BrandLogo theme="dark" className="size-7" alt="" fallback={<span className="size-7 bg-current" style={{ mask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat" }} />} />
|
||||
{appearance.brandName}
|
||||
</Link>
|
||||
<button type="button" className="inline-flex items-center gap-2 rounded-full border border-white/16 bg-black/20 px-3 py-1.5 text-[var(--fs-label)] text-white/76 backdrop-blur-xl transition hover:bg-black/35 disabled:cursor-default" onClick={() => setVideoActive(true)} disabled={videoActive || !appearance.authVideoUrl} aria-pressed={videoActive}>
|
||||
<button type="button" className="inline-flex items-center gap-2 rounded-full border border-white/16 bg-black/20 px-3 py-1.5 text-[var(--fs-label)] text-white/76 backdrop-blur-xl transition hover:bg-black/35 disabled:cursor-default" onClick={playVideo} disabled={videoPlaying || !appearance.authVideoUrl} aria-pressed={videoPlaying}>
|
||||
<Play className="size-3 fill-current" />
|
||||
{videoActive ? "创作正在发生" : "播放品牌影片"}
|
||||
{videoPlaying ? "创作正在发生" : "播放品牌影片"}
|
||||
</button>
|
||||
</div>
|
||||
<motion.div
|
||||
|
||||
@@ -12,6 +12,7 @@ export type PublicAppearance = {
|
||||
logoFrameEnabled: boolean;
|
||||
authVideoUrl: string;
|
||||
authVideoPosterUrl: string;
|
||||
authVideoAutoplay: boolean;
|
||||
skinId: string;
|
||||
activeSkin: SkinDefinition;
|
||||
seoTitle: string;
|
||||
@@ -40,6 +41,7 @@ export type AdminAppearance = {
|
||||
logoFrameEnabled: boolean;
|
||||
authVideoResourceId: string;
|
||||
authVideoPosterResourceId: string;
|
||||
authVideoAutoplay: boolean;
|
||||
skinId: string;
|
||||
skinThemes: SkinDefinition[];
|
||||
seoTitle: string;
|
||||
@@ -87,6 +89,7 @@ export async function updateAdminAppearance(
|
||||
| "logoFrameEnabled"
|
||||
| "authVideoResourceId"
|
||||
| "authVideoPosterResourceId"
|
||||
| "authVideoAutoplay"
|
||||
| "skinId"
|
||||
| "skinThemes"
|
||||
| "seoTitle"
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { PublicAppearance } from "@/services/api/appearance";
|
||||
import { applySkinTheme, DEFAULT_CLASSIC_SKIN, normalizeSkinDefinition } from "@/lib/skin-themes";
|
||||
|
||||
export const DEFAULT_PUBLIC_APPEARANCE: PublicAppearance = {
|
||||
schemaVersion: 6,
|
||||
schemaVersion: 7,
|
||||
brandName: "影策",
|
||||
brandSlug: "open-ai-canvas",
|
||||
authHeroTitle: "让一个故事,\n从文字走向银幕。",
|
||||
@@ -14,6 +14,7 @@ export const DEFAULT_PUBLIC_APPEARANCE: PublicAppearance = {
|
||||
logoFrameEnabled: true,
|
||||
authVideoUrl: "https://boss-shjd.biliapi.net/updream/aniforge/video/video_bbcb00bd-650d-4249-9346-5cd21fd2484c_m1hc-u0-1pu13x-3v1s.mp4",
|
||||
authVideoPosterUrl: "https://i0.hdslb.com/bfs/aitool/aniforge/image/02933f26-5f1b-49ff-a811-b7f95ee5e5b8_m1hc-u0-sau.jpg",
|
||||
authVideoAutoplay: true,
|
||||
skinId: "classic",
|
||||
activeSkin: DEFAULT_CLASSIC_SKIN,
|
||||
seoTitle: "影策",
|
||||
@@ -59,7 +60,7 @@ export function normalizePublicAppearance(value?: Partial<PublicAppearance> | nu
|
||||
return {
|
||||
...DEFAULT_PUBLIC_APPEARANCE,
|
||||
...value,
|
||||
schemaVersion: 6,
|
||||
schemaVersion: 7,
|
||||
brandName: resolvedBrandName,
|
||||
brandSlug,
|
||||
authHeroTitle,
|
||||
@@ -69,6 +70,7 @@ export function normalizePublicAppearance(value?: Partial<PublicAppearance> | nu
|
||||
logoFrameEnabled: value?.logoFrameEnabled !== false,
|
||||
authVideoUrl: safeAppearanceURL(value?.authVideoUrl, DEFAULT_PUBLIC_APPEARANCE.authVideoUrl),
|
||||
authVideoPosterUrl: safeAppearanceURL(value?.authVideoPosterUrl, customVideo ? "" : DEFAULT_PUBLIC_APPEARANCE.authVideoPosterUrl),
|
||||
authVideoAutoplay: value?.authVideoAutoplay !== false,
|
||||
skinId: normalizeSkinDefinition(value?.activeSkin).id,
|
||||
activeSkin: normalizeSkinDefinition(value?.activeSkin),
|
||||
seoTitle,
|
||||
|
||||
@@ -248,6 +248,50 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.admin-appearance-video-autoplay-option {
|
||||
display: flex;
|
||||
min-height: 72px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--admin-layer-2);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.admin-appearance-video-autoplay-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-appearance-video-autoplay-copy strong {
|
||||
color: var(--admin-text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.admin-appearance-video-autoplay-copy small,
|
||||
.admin-appearance-video-autoplay-control > span {
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-appearance-video-autoplay-control {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-appearance-video-autoplay-control > span {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-appearance-asset-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
@@ -1222,6 +1266,16 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.admin-appearance-video-autoplay-option {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-appearance-video-autoplay-control {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.admin-appearance-asset-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
|
||||
@@ -28,6 +28,12 @@ test("a custom login video never falls back to the built-in poster", () => {
|
||||
expect(appearance.authHeroTitle).toBe("把灵感,\n变成可见的故事。");
|
||||
expect(appearance.authHeroDescription).toBe("从同一个创作空间持续推进。");
|
||||
expect(appearance.authVideoPosterUrl).toBe("");
|
||||
expect(appearance.authVideoAutoplay).toBe(true);
|
||||
});
|
||||
|
||||
test("login video autoplay defaults on and can be disabled explicitly", () => {
|
||||
expect(normalizePublicAppearance({}).authVideoAutoplay).toBe(true);
|
||||
expect(normalizePublicAppearance({ authVideoAutoplay: false }).authVideoAutoplay).toBe(false);
|
||||
});
|
||||
|
||||
test("appearance URLs reject executable and insecure remote schemes", () => {
|
||||
@@ -64,6 +70,7 @@ test("auth scene consumes resolved appearance instead of hardcoded media constan
|
||||
const source = await Bun.file(new URL("../src/pages/auth/auth-scene.tsx", import.meta.url)).text();
|
||||
|
||||
expect(source).toContain("appearance.authVideoUrl");
|
||||
expect(source).toContain("appearance.authVideoAutoplay");
|
||||
expect(source).toContain("appearance.authVideoPosterUrl || undefined");
|
||||
expect(source).toContain("appearance.brandName");
|
||||
expect(source).toContain("appearance.authHeroTitle");
|
||||
@@ -91,8 +98,14 @@ test("appearance management exposes light and dark logo uploads plus the frame s
|
||||
expect(pageSource).toContain("setLogoFrameEnabled(!checked)");
|
||||
expect(pageSource).not.toContain("<Checkbox");
|
||||
expect(pageSource).toContain("深浅模式 Logo 预览");
|
||||
expect(pageSource).toContain("登录页视频自动播放");
|
||||
expect(pageSource).toContain("authVideoAutoplay");
|
||||
expect(brandSource).toContain("useThemeStore");
|
||||
expect(brandSource).toContain("data-logo-frame-enabled");
|
||||
expect(brandSource).toContain("failedSource === source");
|
||||
expect(brandSource).toContain('aria-hidden="true"');
|
||||
expect(brandSource).toContain('style.visibility = "hidden"');
|
||||
expect(brandSource).toContain("setFailedSource(source)");
|
||||
expect(adminStyles).toContain(".admin-appearance-logo-preview-mark.is-unframed img");
|
||||
expect(globalStyles).toContain('.brand-logo-frame[data-logo-frame-enabled="false"] > :is(img, svg)');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user