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

View File

@ -1,5 +1,4 @@
import { cookies } from "next/headers";
import Script from "next/script";
import { Suspense } from "react";
import { ChatShell } from "@/components/chat-shell";
import { DataStreamProvider } from "@/components/data-stream-provider";
@ -13,17 +12,11 @@ import {
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Script
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
strategy="beforeInteractive"
/>
<DataStreamProvider>
<Suspense fallback={<div className="flex h-dvh" />}>
<SidebarWrapper>{children}</SidebarWrapper>
</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";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2 } from "lucide-react";
type VideoCanvasRedirectPageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
export default function VideoCanvasRedirectPage() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
const shouldCreateNew = searchParams.get("new") === "1";
const openVideoCanvas = async () => {
if (shouldCreateNew) {
const newId = crypto.randomUUID();
router.replace(`/canvas/${newId}?mode=video&workspaceType=video`);
return;
}
try {
const response = await fetch(
"/api/canvas?workspaceType=video&limit=1&page=1",
{ cache: "no-store" }
);
if (response.ok) {
const result = await response.json();
const items = result?.data || [];
if (Array.isArray(items) && items.length > 0) {
const sorted = [...items].sort((a, b) => {
const aTime = new Date(a.updatedAt || a.createdAt || 0).getTime();
const bTime = new Date(b.updatedAt || b.createdAt || 0).getTime();
return bTime - aTime;
});
const latest = sorted[0];
if (latest?.id) {
router.replace(`/canvas/${latest.id}?mode=video&workspaceType=video`);
return;
}
}
}
} catch (_error) {
// ignore and fallback
}
const newId = crypto.randomUUID();
router.replace(`/canvas/${newId}?mode=video&workspaceType=video`);
};
void openVideoCanvas();
}, [router, searchParams]);
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">正在打开视频画布...</span>
</div>
</div>
);
function getSearchParam(
params: Record<string, string | string[] | undefined>,
key: string
) {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
}
function buildVideoCanvasUrl(
canvasId: string,
params: Record<string, string | string[] | undefined>
) {
const query = new URLSearchParams();
query.set("mode", "video");
query.set("workspaceType", "video");
for (const [key, value] of Object.entries(params)) {
if (
key === "new" ||
key === "mode" ||
key === "workspaceType" ||
value === undefined
) {
continue;
}
if (Array.isArray(value)) {
value.forEach((item) => query.append(key, item));
continue;
}
query.set(key, value);
}
return `/canvas/${canvasId}?${query.toString()}`;
}
async function getLatestVideoCanvasId(userId: string) {
const [latestProject] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, userId),
eq(canvasProject.workspaceType, "video")
)
)
.orderBy(desc(canvasProject.updatedAt))
.limit(1);
return latestProject?.id || null;
}
export default async function VideoCanvasRedirectPage({
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";
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
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";
import type { ChatShellChromeProps } from "@/components/chat-shell-chrome";
type CustomLink = {
id: string;
@ -46,6 +45,17 @@ type ChatShellProps = {
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({
children,
user,
@ -81,30 +91,27 @@ export function ChatShell({
return <div className="h-svh w-full overflow-hidden">{children}</div>;
}
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>
);
const chromeProps: ChatShellChromeProps = {
agentEnabled,
children,
customLinks,
isAdmin,
isCollapsed,
productPlanningEnabled,
rankingEnabled,
settingsHref,
siteLogo,
siteLogoDark,
siteName,
toolbox1Enabled,
toolbox1Name,
toolbox2Enabled,
toolbox2Name,
tutorialEnabled,
tutorialVideos,
user,
videoCanvasEnabled,
};
return <ChatShellChrome {...chromeProps} />;
}

View File

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