perf: speed up home and canvas entry
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-29 23:32:21 +08:00
parent a7b9533d72
commit f7dba9cb41
6 changed files with 291 additions and 163 deletions

View File

@ -1,66 +1,71 @@
"use client"; import { randomUUID } from "node:crypto";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { and, desc, eq } from "drizzle-orm";
import { useEffect } from "react"; type CanvasRedirectPageProps = {
import { useRouter, useSearchParams } from "next/navigation"; searchParams?: Promise<Record<string, string | string[] | undefined>>;
import { Loader2 } from "lucide-react"; };
// 默认进入最近画布,避免误触直接新建 function getSearchParam(
export default function CanvasRedirectPage() { params: Record<string, string | string[] | undefined>,
const router = useRouter(); key: string
const searchParams = useSearchParams(); ) {
const value = params[key];
useEffect(() => { return Array.isArray(value) ? value[0] : value;
const shouldCreateNew = searchParams.get("new") === "1"; }
const passthroughParams = new URLSearchParams(searchParams.toString());
passthroughParams.delete("new"); function buildCanvasUrl(
const passthroughQuery = passthroughParams.toString(); canvasId: string,
const buildCanvasUrl = (canvasId: string) => params: Record<string, string | string[] | undefined>
`/canvas/${canvasId}${passthroughQuery ? `?${passthroughQuery}` : ""}`; ) {
const query = new URLSearchParams();
const openCanvas = async () => { for (const [key, value] of Object.entries(params)) {
if (shouldCreateNew) { if (key === "new" || value === undefined) {
const newId = crypto.randomUUID(); continue;
router.replace(buildCanvasUrl(newId)); }
return; if (Array.isArray(value)) {
} value.forEach((item) => query.append(key, item));
continue;
try { }
const response = await fetch("/api/canvas?limit=1&page=1", { query.set(key, value);
cache: "no-store", }
}); const queryString = query.toString();
if (response.ok) { return `/canvas/${canvasId}${queryString ? `?${queryString}` : ""}`;
const result = await response.json(); }
const items = result?.data || [];
if (Array.isArray(items) && items.length > 0) { async function getLatestCanvasId(userId: string) {
const sorted = [...items].sort((a, b) => { const [latestProject] = await db
const aTime = new Date(a.updatedAt || a.createdAt || 0).getTime(); .select({ id: canvasProject.id })
const bTime = new Date(b.updatedAt || b.createdAt || 0).getTime(); .from(canvasProject)
return bTime - aTime; .where(
}); and(
const latest = sorted[0]; eq(canvasProject.userId, userId),
if (latest?.id) { eq(canvasProject.workspaceType, "canvas")
router.replace(buildCanvasUrl(latest.id)); )
return; )
} .orderBy(desc(canvasProject.updatedAt))
} .limit(1);
}
} catch (_error) { return latestProject?.id || null;
// ignore and fallback }
}
export default async function CanvasRedirectPage({
const newId = crypto.randomUUID(); searchParams,
router.replace(buildCanvasUrl(newId)); }: CanvasRedirectPageProps) {
}; const params = (await searchParams) || {};
const shouldCreateNew = getSearchParam(params, "new") === "1";
void openCanvas();
}, [router, searchParams]); if (shouldCreateNew) {
redirect(buildCanvasUrl(randomUUID(), params));
return ( }
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-3"> const session = await auth();
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> const latestCanvasId = session?.user?.id
<span className="text-sm text-muted-foreground">正在打开画布...</span> ? await getLatestCanvasId(session.user.id).catch(() => null)
</div> : null;
</div>
); redirect(buildCanvasUrl(latestCanvasId || randomUUID(), params));
} }

View File

@ -1,5 +1,4 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import Script from "next/script";
import { Suspense } from "react"; import { Suspense } from "react";
import { ChatShell } from "@/components/chat-shell"; import { ChatShell } from "@/components/chat-shell";
import { DataStreamProvider } from "@/components/data-stream-provider"; import { DataStreamProvider } from "@/components/data-stream-provider";
@ -13,17 +12,11 @@ import {
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<> <DataStreamProvider>
<Script <Suspense fallback={<div className="flex h-dvh" />}>
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js" <SidebarWrapper>{children}</SidebarWrapper>
strategy="beforeInteractive" </Suspense>
/> </DataStreamProvider>
<DataStreamProvider>
<Suspense fallback={<div className="flex h-dvh" />}>
<SidebarWrapper>{children}</SidebarWrapper>
</Suspense>
</DataStreamProvider>
</>
); );
} }

View File

@ -1,61 +1,79 @@
"use client"; import { randomUUID } from "node:crypto";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { and, desc, eq } from "drizzle-orm";
import { useEffect } from "react"; type VideoCanvasRedirectPageProps = {
import { useRouter, useSearchParams } from "next/navigation"; searchParams?: Promise<Record<string, string | string[] | undefined>>;
import { Loader2 } from "lucide-react"; };
export default function VideoCanvasRedirectPage() { function getSearchParam(
const router = useRouter(); params: Record<string, string | string[] | undefined>,
const searchParams = useSearchParams(); key: string
) {
useEffect(() => { const value = params[key];
const shouldCreateNew = searchParams.get("new") === "1"; return Array.isArray(value) ? value[0] : value;
}
const openVideoCanvas = async () => {
if (shouldCreateNew) { function buildVideoCanvasUrl(
const newId = crypto.randomUUID(); canvasId: string,
router.replace(`/canvas/${newId}?mode=video&workspaceType=video`); params: Record<string, string | string[] | undefined>
return; ) {
} const query = new URLSearchParams();
query.set("mode", "video");
try { query.set("workspaceType", "video");
const response = await fetch(
"/api/canvas?workspaceType=video&limit=1&page=1", for (const [key, value] of Object.entries(params)) {
{ cache: "no-store" } if (
); key === "new" ||
if (response.ok) { key === "mode" ||
const result = await response.json(); key === "workspaceType" ||
const items = result?.data || []; value === undefined
if (Array.isArray(items) && items.length > 0) { ) {
const sorted = [...items].sort((a, b) => { continue;
const aTime = new Date(a.updatedAt || a.createdAt || 0).getTime(); }
const bTime = new Date(b.updatedAt || b.createdAt || 0).getTime(); if (Array.isArray(value)) {
return bTime - aTime; value.forEach((item) => query.append(key, item));
}); continue;
const latest = sorted[0]; }
if (latest?.id) { query.set(key, value);
router.replace(`/canvas/${latest.id}?mode=video&workspaceType=video`); }
return;
} return `/canvas/${canvasId}?${query.toString()}`;
} }
}
} catch (_error) { async function getLatestVideoCanvasId(userId: string) {
// ignore and fallback const [latestProject] = await db
} .select({ id: canvasProject.id })
.from(canvasProject)
const newId = crypto.randomUUID(); .where(
router.replace(`/canvas/${newId}?mode=video&workspaceType=video`); and(
}; eq(canvasProject.userId, userId),
eq(canvasProject.workspaceType, "video")
void openVideoCanvas(); )
}, [router, searchParams]); )
.orderBy(desc(canvasProject.updatedAt))
return ( .limit(1);
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-3"> return latestProject?.id || null;
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> }
<span className="text-sm text-muted-foreground">正在打开视频画布...</span>
</div> export default async function VideoCanvasRedirectPage({
</div> searchParams,
); }: VideoCanvasRedirectPageProps) {
const params = (await searchParams) || {};
const shouldCreateNew = getSearchParam(params, "new") === "1";
if (shouldCreateNew) {
redirect(buildVideoCanvasUrl(randomUUID(), params));
}
const session = await auth();
const latestCanvasId = session?.user?.id
? await getLatestVideoCanvasId(session.user.id).catch(() => null)
: null;
redirect(buildVideoCanvasUrl(latestCanvasId || randomUUID(), params));
} }

View File

@ -0,0 +1,94 @@
"use client";
import type { User } from "next-auth";
import { AppSidebar } from "@/components/app-sidebar";
import { TopNavbar } from "@/components/top-navbar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
type CustomLink = {
id: string;
name: string;
url: string;
order: number;
toolboxGroup: number;
};
type TutorialVideo = {
id: string;
title: string;
videoUrl: string;
coverUrl: string | null;
description: string | null;
order: number;
};
export type ChatShellChromeProps = {
children: React.ReactNode;
user?: User;
isCollapsed: boolean;
siteName: string;
siteLogo?: string;
siteLogoDark?: string;
isAdmin: boolean;
settingsHref?: string | null;
customLinks: CustomLink[];
toolbox1Name: string;
toolbox1Enabled: boolean;
toolbox2Name: string;
toolbox2Enabled: boolean;
tutorialVideos: TutorialVideo[];
tutorialEnabled: boolean;
rankingEnabled: boolean;
productPlanningEnabled: boolean;
agentEnabled: boolean;
videoCanvasEnabled: boolean;
};
export function ChatShellChrome({
children,
user,
isCollapsed,
siteName,
siteLogo,
siteLogoDark,
isAdmin,
settingsHref = null,
customLinks,
toolbox1Name,
toolbox1Enabled,
toolbox2Name,
toolbox2Enabled,
tutorialVideos,
tutorialEnabled,
rankingEnabled,
productPlanningEnabled,
agentEnabled,
videoCanvasEnabled,
}: ChatShellChromeProps) {
return (
<SidebarProvider defaultOpen={!isCollapsed}>
<TopNavbar
agentEnabled={agentEnabled}
customLinks={customLinks}
isAdmin={isAdmin}
productPlanningEnabled={productPlanningEnabled}
rankingEnabled={rankingEnabled}
settingsHref={settingsHref}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
siteName={siteName}
toolbox1Enabled={toolbox1Enabled}
toolbox1Name={toolbox1Name}
toolbox2Enabled={toolbox2Enabled}
toolbox2Name={toolbox2Name}
tutorialEnabled={tutorialEnabled}
tutorialVideos={tutorialVideos}
videoCanvasEnabled={videoCanvasEnabled}
/>
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
<AppSidebar user={user} />
<SidebarInset className="h-full">{children}</SidebarInset>
</div>
</SidebarProvider>
);
}

View File

@ -1,10 +1,9 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import type { User } from "next-auth"; import type { User } from "next-auth";
import { AppSidebar } from "@/components/app-sidebar"; import type { ChatShellChromeProps } from "@/components/chat-shell-chrome";
import { TopNavbar } from "@/components/top-navbar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
type CustomLink = { type CustomLink = {
id: string; id: string;
@ -46,6 +45,17 @@ type ChatShellProps = {
videoCanvasEnabled: boolean; videoCanvasEnabled: boolean;
}; };
const ChatShellChrome = dynamic(
() =>
import("@/components/chat-shell-chrome").then(
(module) => module.ChatShellChrome
),
{
loading: () => <div className="h-svh w-full overflow-hidden" />,
ssr: false,
}
);
export function ChatShell({ export function ChatShell({
children, children,
user, user,
@ -81,30 +91,27 @@ export function ChatShell({
return <div className="h-svh w-full overflow-hidden">{children}</div>; return <div className="h-svh w-full overflow-hidden">{children}</div>;
} }
return ( const chromeProps: ChatShellChromeProps = {
<SidebarProvider defaultOpen={!isCollapsed}> agentEnabled,
<TopNavbar children,
agentEnabled={agentEnabled} customLinks,
customLinks={customLinks} isAdmin,
isAdmin={isAdmin} isCollapsed,
productPlanningEnabled={productPlanningEnabled} productPlanningEnabled,
rankingEnabled={rankingEnabled} rankingEnabled,
settingsHref={settingsHref} settingsHref,
siteLogo={siteLogo} siteLogo,
siteLogoDark={siteLogoDark} siteLogoDark,
siteName={siteName} siteName,
toolbox1Enabled={toolbox1Enabled} toolbox1Enabled,
toolbox1Name={toolbox1Name} toolbox1Name,
toolbox2Enabled={toolbox2Enabled} toolbox2Enabled,
toolbox2Name={toolbox2Name} toolbox2Name,
tutorialEnabled={tutorialEnabled} tutorialEnabled,
tutorialVideos={tutorialVideos} tutorialVideos,
videoCanvasEnabled={videoCanvasEnabled} user,
/> videoCanvasEnabled,
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]"> };
<AppSidebar user={user} />
<SidebarInset className="h-full">{children}</SidebarInset> return <ChatShellChrome {...chromeProps} />;
</div>
</SidebarProvider>
);
} }

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { signIn, signOut } from "next-auth/react"; import { signIn, signOut } from "next-auth/react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
@ -65,7 +66,6 @@ import {
getCanvasLaunchStorageKey, getCanvasLaunchStorageKey,
} from "@/lib/canvas-launch"; } from "@/lib/canvas-launch";
import type { MembershipConfig } from "@/lib/membership/config"; import type { MembershipConfig } from "@/lib/membership/config";
import { ImageAnnotator } from "@/components/image-annotator";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed"; import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
import { FloatingSidebarRail } from "@/components/home/floating-sidebar-shell"; import { FloatingSidebarRail } from "@/components/home/floating-sidebar-shell";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
@ -74,6 +74,17 @@ import {
type LovartHomeSidebarItemKey, type LovartHomeSidebarItemKey,
} from "@/lib/home/lovart-sidebar-config"; } from "@/lib/home/lovart-sidebar-config";
const ImageAnnotator = dynamic(
() =>
import("@/components/image-annotator").then(
(module) => module.ImageAnnotator
),
{
loading: () => null,
ssr: false,
}
);
type HomeUser = { type HomeUser = {
id: string; id: string;
email?: string | null; email?: string | null;