diff --git a/cmd/server/router.go b/cmd/server/router.go index de59636..fb387ba 100644 --- a/cmd/server/router.go +++ b/cmd/server/router.go @@ -70,8 +70,11 @@ func serveSPA(r *gin.Engine, root fs.FS) { r.GET(rootFile, serveFSFile(root, name)) r.HEAD(rootFile, serveFSFile(root, name)) } - r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) { - path := c.Request.URL.Path + r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) { + if handler.TryHandleEmbyNormalizedRoute(c, r) { + return + } + path := c.Request.URL.Path if shouldBypassSPAFallback(path) { c.Status(http.StatusNotFound) return diff --git a/internal/handler/emby_auth.go b/internal/handler/emby_auth.go index 5704f6a..f960355 100644 --- a/internal/handler/emby_auth.go +++ b/internal/handler/emby_auth.go @@ -257,7 +257,10 @@ func embyClientInfoFromRequest(c *gin.Context) embyClientInfo { firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"), c.Query("Client"), c.Query("client"), + c.Query("X-Emby-Client"), + c.Query("X-MediaBrowser-Client"), auth["Client"], + auth["client"], ), } ua := strings.TrimSpace(c.GetHeader("User-Agent")) @@ -315,10 +318,36 @@ func embyClientFromUserAgent(ua string) string { return "Emby" case strings.Contains(lower, "jellyfin"): return "Jellyfin" + case strings.Contains(lower, "capyplayer") || strings.Contains(lower, "capy player") || strings.Contains(lower, "卡皮巴拉"): + return "CapyPlayer" + case strings.Contains(lower, "senplayer") || strings.Contains(lower, "sen player") || strings.Contains(lower, "森播"): + return "SenPlayer" case strings.Contains(lower, "yamby"): return "Yamby" case strings.Contains(lower, "vidhub"): return "VidHub" + case strings.Contains(lower, "fileball"): + return "Fileball" + case strings.Contains(lower, "hamhub"): + return "HamHub" + case strings.Contains(lower, "afusekt") || strings.Contains(lower, "afuse"): + return "AfuseKt" + case strings.Contains(lower, "cony"): + return "Cony" + case strings.Contains(lower, "kodi"): + return "Kodi" + case strings.Contains(lower, "mrmc"): + return "MrMC" + case strings.Contains(lower, "forward"): + return "Forward" + case strings.Contains(lower, "alpha"): + return "Alpha" + case strings.Contains(lower, "dandanplay") || strings.Contains(lower, "弹弹play"): + return "DanDanPlay" + case strings.Contains(lower, "potplayer"): + return "PotPlayer" + case strings.Contains(lower, "vlc"): + return "VLC" case strings.Contains(lower, "hills"): return "Hills" default: @@ -343,7 +372,7 @@ func embyDeviceFromUserAgent(ua string) string { return "Mac" case strings.Contains(lower, "linux"): return "Linux PC" - case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"): + case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv") || strings.Contains(lower, "appletvos"): return "Apple TV" default: return "" diff --git a/internal/handler/emby_compat_features_test.go b/internal/handler/emby_compat_features_test.go new file mode 100644 index 0000000..cfa4be3 --- /dev/null +++ b/internal/handler/emby_compat_features_test.go @@ -0,0 +1,318 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +func TestNormalizeEmbyPath(t *testing.T) { + tests := []struct { + input string + wantPath string + changed bool + }{ + { + input: "/emby/System/Info", + wantPath: "/emby/system/info", + changed: true, + }, + { + input: "/emby/emby/System/Info", + wantPath: "/emby/system/info", + changed: true, + }, + { + input: "/emby/emby/emby/items/123/playbackInfo", + wantPath: "/emby/items/123/playbackinfo", + changed: true, + }, + { + input: "//emby//System//Info//Public", + wantPath: "/emby/system/info/public", + changed: true, + }, + { + input: "/Items/msgo-series-1/PlaybackInfo", + wantPath: "/items/msgo-series-1/playbackinfo", + changed: true, + }, + { + input: "/Videos/m-123/Master.m3u8", + wantPath: "/videos/m-123/master.m3u8", + changed: true, + }, + { + input: "/api/unknown/other", + wantPath: "/api/unknown/other", + changed: false, + }, + } + + for _, tt := range tests { + gotPath, changed := NormalizeEmbyPath(tt.input) + if gotPath != tt.wantPath || changed != tt.changed { + t.Errorf("NormalizeEmbyPath(%q) = (%q, %v), want (%q, %v)", tt.input, gotPath, changed, tt.wantPath, tt.changed) + } + } +} + +func TestEmbyDuplicatePrefixHandling(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + svc := &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(nil, nil, repos), + } + + router := gin.New() + registerEmbyRoutes(router, "secret", svc) + + // 模拟重复拼接前缀的客户端请求: /emby/emby/System/Info/Public + req := httptest.NewRequest(http.MethodGet, "/emby/emby/System/Info/Public", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK for /emby/emby/System/Info/Public, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "ServerName") { + t.Fatalf("expected server info body, got: %s", w.Body.String()) + } +} + +func TestEmbyMixedCaseHandling(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + svc := &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(nil, nil, repos), + } + + router := gin.New() + registerEmbyRoutes(router, "secret", svc) + + // 混合大小写驼峰: /emby/system/Info/Public + req := httptest.NewRequest(http.MethodGet, "/emby/system/Info/Public", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK for /emby/system/Info/Public, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestEmbyClientIdentification(t *testing.T) { + tests := []struct { + name string + ua string + query string + headerAuth string + wantClient string + }{ + { + name: "CapyPlayer via UA", + ua: "CapyPlayer/1.2.0 (iOS)", + wantClient: "CapyPlayer", + }, + { + name: "SenPlayer via UA", + ua: "SenPlayer/2.1", + wantClient: "SenPlayer", + }, + { + name: "Fileball via UA", + ua: "Fileball/1.0.0", + wantClient: "Fileball", + }, + { + name: "Kodi via UA", + ua: "Kodi/20.2", + wantClient: "Kodi", + }, + { + name: "Client in query", + ua: "CustomApp/1.0", + query: "?X-Emby-Client=CapyPlayer", + wantClient: "CapyPlayer", + }, + { + name: "Client in auth header", + ua: "Custom/1.0", + headerAuth: `MediaBrowser Client="SenPlayer", Device="AppleTV", DeviceId="abc"`, + wantClient: "SenPlayer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + url := "/test" + if tt.query != "" { + url += tt.query + } + req := httptest.NewRequest(http.MethodGet, url, nil) + if tt.ua != "" { + req.Header.Set("User-Agent", tt.ua) + } + if tt.headerAuth != "" { + req.Header.Set("X-Emby-Authorization", tt.headerAuth) + } + c.Request = req + + info := embyClientInfoFromRequest(c) + if info.Client != tt.wantClient { + t.Fatalf("embyClientInfoFromRequest Client = %q, want %q", info.Client, tt.wantClient) + } + }) + } +} + +func TestEmbyAdaptivePrefixPlaybackInfo(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + svc := &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(nil, nil, repos), + } + const secret = "test-secret" + router := gin.New() + registerEmbyRoutes(router, secret, svc) + + token := signedTestToken(t, secret) + if err := repos.User.Create(t.Context(), &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "tester", + PasswordHash: "x", + Role: "admin", + Tier: "plus", + IsActive: true, + }); err != nil { + t.Fatalf("create user: %v", err) + } + if err := db.Create(&model.Media{ + Base: model.Base{ID: "m-adaptive-1"}, + Title: "测试媒体", + Path: "D:\\media\\test.mkv", + LibraryID: "lib-1", + }).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + // 1. 从 /emby 前缀请求 PlaybackInfo + req1 := httptest.NewRequest(http.MethodGet, "/emby/Items/m-adaptive-1/PlaybackInfo", nil) + req1.Header.Set("X-Emby-Token", token) + w1 := httptest.NewRecorder() + router.ServeHTTP(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("playbackinfo /emby code = %d: %s", w1.Code, w1.Body.String()) + } + var res1 struct { + MediaSources []struct { + DirectStreamURL string `json:"DirectStreamUrl"` + } `json:"MediaSources"` + } + if err := json.Unmarshal(w1.Body.Bytes(), &res1); err != nil || len(res1.MediaSources) == 0 { + t.Fatalf("unmarshal /emby response: %v, body: %s", err, w1.Body.String()) + } + if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "/Videos/m-adaptive-1/stream") { + t.Fatalf("DirectStreamUrl should point to video stream endpoint, got: %s", res1.MediaSources[0].DirectStreamURL) + } + if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "api_key="+token) { + t.Fatalf("DirectStreamUrl should carry api_key token, got: %s", res1.MediaSources[0].DirectStreamURL) + } + + // 2. 从重复前缀 /emby/emby 请求 PlaybackInfo (模拟客户端再次追加 BaseUrl 场景) + req2 := httptest.NewRequest(http.MethodGet, "/emby/emby/Items/m-adaptive-1/PlaybackInfo", nil) + req2.Header.Set("X-Emby-Token", token) + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("playbackinfo /emby/emby code = %d: %s", w2.Code, w2.Body.String()) + } + var res2 struct { + MediaSources []struct { + DirectStreamURL string `json:"DirectStreamUrl"` + } `json:"MediaSources"` + } + if err := json.Unmarshal(w2.Body.Bytes(), &res2); err != nil || len(res2.MediaSources) == 0 { + t.Fatalf("unmarshal /emby/emby response: %v, body: %s", err, w2.Body.String()) + } + + // 3. 从根路径 /Items 请求 PlaybackInfo + req3 := httptest.NewRequest(http.MethodGet, "/Items/m-adaptive-1/PlaybackInfo", nil) + req3.Header.Set("X-Emby-Token", token) + w3 := httptest.NewRecorder() + router.ServeHTTP(w3, req3) + + if w3.Code != http.StatusOK { + t.Fatalf("playbackinfo root code = %d: %s", w3.Code, w3.Body.String()) + } +} + +func TestEmbyImageClearNoStore(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + svc := &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(nil, nil, repos), + } + + router := gin.New() + registerEmbyRoutes(router, "secret", svc) + + req := httptest.NewRequest(http.MethodGet, "/emby/Items/non-existent-item/Images/Primary", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("placeholder image should return 200, got %d", w.Code) + } + cacheControl := w.Header().Get("Cache-Control") + if strings.Contains(cacheControl, "no-store") { + t.Fatalf("image response should not have no-store, got: %s", cacheControl) + } + if !strings.Contains(cacheControl, "public") { + t.Fatalf("image response should have public cache-control, got: %s", cacheControl) + } +} diff --git a/internal/handler/emby_images.go b/internal/handler/emby_images.go index c279e9b..9f6f50b 100644 --- a/internal/handler/emby_images.go +++ b/internal/handler/emby_images.go @@ -51,13 +51,14 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { } func clearEmbyImageNoStoreHeaders(c *gin.Context) { + c.Writer.Header().Del("Cache-Control") c.Writer.Header().Del("Pragma") c.Writer.Header().Del("Expires") } func embyServePlaceholderImage(c *gin.Context) { c.Header("Content-Type", "image/png") - c.Header("Cache-Control", "public, max-age=3600") + c.Header("Cache-Control", "public, max-age=86400") c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG))) if c.Request.Method == http.MethodHead { c.Status(http.StatusOK) diff --git a/internal/handler/emby_path_normalize.go b/internal/handler/emby_path_normalize.go new file mode 100644 index 0000000..74808f6 --- /dev/null +++ b/internal/handler/emby_path_normalize.go @@ -0,0 +1,190 @@ +package handler + +import ( + "regexp" + "strings" + + "github.com/gin-gonic/gin" +) + +var ( + multipleSlashesRE = regexp.MustCompile(`/{2,}`) +) + +// embyStaticSegments 包含 Emby API 中已知的保留静态路由分段(全部小写), +// 用于在遇到客户端混合大小写(如 /System/info, /items/:id/playbackInfo)时, +// 将静态段规范化为对应的小写形式,而保留动态参数段(:id, :userId 等)的原大小写。 +var embyStaticSegments = map[string]struct{}{ + "system": {}, + "info": {}, + "public": {}, + "endpoint": {}, + "configuration": {}, + "ping": {}, + "users": {}, + "useritems": {}, + "me": {}, + "authenticatebyname": {}, + "items": {}, + "counts": {}, + "latest": {}, + "resume": {}, + "playbackinfo": {}, + "shows": {}, + "seasons": {}, + "episodes": {}, + "nextup": {}, + "upcoming": {}, + "similar": {}, + "thumbnailset": {}, + "thememedia": {}, + "specialfeatures": {}, + "intros": {}, + "videos": {}, + "stream": {}, + "subtitles": {}, + "master.m3u8": {}, + "main.m3u8": {}, + "sessions": {}, + "playing": {}, + "progress": {}, + "stopped": {}, + "capabilities": {}, + "full": {}, + "logout": {}, + "views": {}, + "library": {}, + "mediafolders": {}, + "virtualfolders": {}, + "selectablemediafolders": {}, + "branding": {}, + "css": {}, + "localization": {}, + "options": {}, + "cultures": {}, + "customcssjs": {}, + "scripts": {}, + "displaypreferences": {}, + "quickconnect": {}, + "enabled": {}, + "startup": {}, + "complete": {}, + "favoriteitems": {}, + "playeditems": {}, + "images": {}, + "primary": {}, + "backdrop": {}, + "banner": {}, + "thumb": {}, + "logo": {}, + "serverdomains": {}, + "ext": {}, + "danmu": {}, + "raw": {}, + "mediasegments": {}, + "artists": {}, + "persons": {}, + "genres": {}, + "embywebsocket": {}, +} + +// NormalizeEmbyPath 规范化 Emby 请求路径: +// 1. 折叠重复斜杠(如 //emby/ -> /emby/); +// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info); +// 3. 将静态关键字段归一化为小写,同时保留动态 ID/参数的原有大小写。 +func NormalizeEmbyPath(p string) (string, bool) { + if p == "" { + return "", false + } + original := p + + // 1. 折叠多余斜杠 + p = multipleSlashesRE.ReplaceAllString(p, "/") + + // 2. 折叠重复的 /emby 前缀 + for { + lower := strings.ToLower(p) + if strings.HasPrefix(lower, "/emby/emby/") { + p = "/emby/" + p[len("/emby/emby/"):] + continue + } + if lower == "/emby/emby" { + p = "/emby" + break + } + break + } + + // 3. 分析是否具有 Emby 路由特征 + hasEmbyPrefix := false + workPath := p + if strings.HasPrefix(strings.ToLower(workPath), "/emby/") { + hasEmbyPrefix = true + workPath = workPath[len("/emby"):] + } else if strings.EqualFold(workPath, "/emby") { + return "/emby", original != "/emby" + } + + segments := strings.Split(strings.Trim(workPath, "/"), "/") + if len(segments) == 0 || segments[0] == "" { + return p, p != original + } + + // 检查第一段是否为 Emby 根路由关键字 + firstLower := strings.ToLower(segments[0]) + if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" { + // 不是 Emby 相关路径,保持原样 + return original, false + } + + // 4. 将已知静态段转为小写,动态段保留原样 + for i, seg := range segments { + segLower := strings.ToLower(seg) + if _, isStatic := embyStaticSegments[segLower]; isStatic { + if seg != segLower { + segments[i] = segLower + } + } + } + + var builder strings.Builder + if hasEmbyPrefix { + builder.WriteString("/emby") + } + for _, seg := range segments { + builder.WriteString("/") + builder.WriteString(seg) + } + if strings.HasSuffix(original, "/") && !strings.HasSuffix(builder.String(), "/") { + builder.WriteString("/") + } + + normalized := builder.String() + return normalized, normalized != original +} + +const embyNormalizedCtxKey = "emby_normalized_path" + +// TryHandleEmbyNormalizedRoute 尝试在 404 NoRoute 阶段对 Emby 路径做前缀与大小写纠偏并重定向分发。 +// 若成功分发并处理,返回 true;否则返回 false。 +func TryHandleEmbyNormalizedRoute(c *gin.Context, r *gin.Engine) bool { + if c == nil || r == nil { + return false + } + if c.GetBool(embyNormalizedCtxKey) { + return false + } + normalized, changed := NormalizeEmbyPath(c.Request.URL.Path) + if !changed { + return false + } + + c.Set(embyNormalizedCtxKey, true) + c.Request.URL.Path = normalized + + // 重置 context 状态并由 engine 重新查找路由树 + c.Params = nil + c.Writer.Header().Del("Content-Type") + r.HandleContext(c) + return true +} diff --git a/internal/handler/emby_routes.go b/internal/handler/emby_routes.go index f231828..84a7a56 100644 --- a/internal/handler/emby_routes.go +++ b/internal/handler/emby_routes.go @@ -23,6 +23,14 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc)) registerEmbyAuthenticatedRoutes(auth, prefix, svc) } + + // 为没有设置 NoRoute 的 Engine 提供 Emby 路径前缀与大小写纠偏(如单测或独立挂载环境) + r.NoRoute(func(c *gin.Context) { + if TryHandleEmbyNormalizedRoute(c, r) { + return + } + c.Status(404) + }) } type embyRouteHandlerFactory func(*service.Container) gin.HandlerFunc diff --git a/internal/service/emby_system.go b/internal/service/emby_system.go index 09b1d11..c416f37 100644 --- a/internal/service/emby_system.go +++ b/internal/service/emby_system.go @@ -11,6 +11,10 @@ import ( // SystemInfo returns the full Emby identity payload. func (e *EmbyService) SystemInfo() map[string]any { + port := 8096 + if e != nil && e.cfg != nil { + port = e.cfg.App.Port + } return map[string]any{ "Id": embyServerID, "ServerId": embyServerID, @@ -27,10 +31,10 @@ func (e *EmbyService) SystemInfo() map[string]any { "SupportsLibraryMonitor": true, "SupportsHttps": false, "SupportsAutoDiscovery": true, - "HttpServerPortNumber": e.cfg.App.Port, + "HttpServerPortNumber": port, "HttpsPortNumber": 0, "PublishedServerUrl": "", - "WebSocketPortNumber": e.cfg.App.Port, + "WebSocketPortNumber": port, "CompletedInstallations": []any{}, "CanSelfRestart": false, "CanLaunchWebBrowser": false, @@ -40,6 +44,10 @@ func (e *EmbyService) SystemInfo() map[string]any { // SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。 func (e *EmbyService) SystemInfoPublic() map[string]any { + port := 8096 + if e != nil && e.cfg != nil { + port = e.cfg.App.Port + } return map[string]any{ "Id": embyServerID, "ServerId": embyServerID, @@ -50,7 +58,7 @@ func (e *EmbyService) SystemInfoPublic() map[string]any { "OperatingSystem": "Windows", "LocalAddress": "", "WanAddress": "", - "HttpServerPortNumber": e.cfg.App.Port, + "HttpServerPortNumber": port, "HttpsPortNumber": 0, "SupportsHttps": false, "SupportsAutoDiscovery": true, diff --git a/web/src/components/LayoutSections.tsx b/web/src/components/LayoutSections.tsx index 528529d..6514d24 100644 --- a/web/src/components/LayoutSections.tsx +++ b/web/src/components/LayoutSections.tsx @@ -137,7 +137,7 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou } return ( -
+
= 1536) return 8 + if (width >= 1280) return 7 + if (width >= 1024) return 6 + if (width >= 768) return 5 + if (width >= 640) return 4 + return 3 +} -const GridList = forwardRef( - function GridList({ children, style, ...rest }, ref) { - return ( -
- {children} -
- ) - }, -) - -const GridItem = forwardRef( - function GridItem({ children, style, ...rest }, ref) { - return ( -
- {children} -
- ) - }, -) - -// VirtualMediaGrid 大库性能优化:无论库里有 1 千还是 5 万张卡片, -// 实际挂载的只有视口内及邻近一屏的节点。滚动容器是 Layout 的 -//
(非 window 滚动),通过 customScrollParent 接入; -// 元素尚未挂载时先渲染一屏卡片,挂载后切换为虚拟列表。 +// VirtualMediaGrid 大库性能优化: +// 采用按行虚拟滚动(Row-based Virtualization)。相比 VirtuosoGrid 强制要求所有网格项 +// 绝对等高且易受 CSS Grid 亚像素尺寸扰动引发死循环闪烁,按行使用基础 Virtuoso 组件 +// 天然支持每行真实高度,并且每一行内部保持原生的响应式 CSS Grid 布局。 export function VirtualMediaGrid({ totalCount, renderItem, @@ -39,29 +26,98 @@ export function VirtualMediaGrid({ totalCount: number renderItem: (index: number) => ReactNode }) { - const [scrollParent, setScrollParent] = useState(null) + const containerRef = useRef(null) + const probeRef = useRef(null) + const [columns, setColumns] = useState(() => { + if (typeof window !== 'undefined') { + return getFallbackColumns(window.innerWidth) + } + return 4 + }) - useEffect(() => { - setScrollParent(document.getElementById('app-main-scroll')) + const updateColumns = useCallback(() => { + if (probeRef.current) { + const computed = window.getComputedStyle(probeRef.current).gridTemplateColumns + if (computed && computed !== 'none') { + const count = computed.trim().split(/\s+/).filter(Boolean).length + if (count > 0) { + setColumns((prev) => (prev !== count ? count : prev)) + return + } + } + } + const width = containerRef.current?.clientWidth || (typeof window !== 'undefined' ? window.innerWidth : 0) + if (width > 0) { + const fallback = getFallbackColumns(width) + setColumns((prev) => (prev !== fallback ? fallback : prev)) + } }, []) - if (!scrollParent) { - return ( -
- {Array.from({ length: Math.min(totalCount, FIRST_PAINT_COUNT) }, (_, index) => ( -
{renderItem(index)}
- ))} -
- ) - } + useLayoutEffect(() => { + updateColumns() + }, [updateColumns]) + + useEffect(() => { + const el = containerRef.current + if (!el || typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateColumns) + return () => window.removeEventListener('resize', updateColumns) + } + const observer = new ResizeObserver(() => { + updateColumns() + }) + observer.observe(el) + return () => observer.disconnect() + }, [updateColumns]) + + const [scrollParent, setScrollParent] = useState(() => { + return typeof document !== 'undefined' ? document.getElementById('app-main-scroll') : null + }) + + useEffect(() => { + if (!scrollParent) { + setScrollParent(document.getElementById('app-main-scroll')) + } + }, [scrollParent]) + + const rowCount = Math.ceil(totalCount / columns) return ( - +
+ {/* 隐藏探针节点:跟随 Tailwind MEDIA_GRID_CLASS 响应式断点自动计算当前列数 */} +