diff --git a/README.md b/README.md
index 7f6049c..27b4baf 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 啡跃 Endoleap
-本地 Next.js Pages Router + TypeScript + Tailwind CSS + shadcn/ui 项目。首页和账户页参考 qyof 的 PROMPT 文件,工作台结合双侧栏画布、Notion 的留白与 Miro 的空间组织方式。Other Designs 用于首页叙事与版式参考。
+本地 Next.js App Router + TypeScript + Tailwind CSS + shadcn/ui 项目。首页和账户页参考 qyof 的 PROMPT 文件,工作台结合双侧栏画布、Notion 的留白与 Miro 的空间组织方式。Other Designs 用于首页叙事与版式参考。
项目目录:`/Users/zzyang/endoleap`。
@@ -19,6 +19,14 @@ npm run dev
- 真实工作台:/dashboard
- 本地演示:/dashboard?demo=1
+## 路由结构
+
+- `src/app/layout.tsx`:根布局、中文语言标记、全局样式与页面元信息。
+- `src/app/page.tsx`:首页。
+- `src/app/login/page.tsx`、`src/app/auth/callback/page.tsx`:账户页与登录回调。
+- `src/app/dashboard/page.tsx`:工作台入口,以 Suspense 包裹读取查询参数的客户端工作台。
+- `src/app/api/**/route.ts`:Foundry 代理与 OAuth 跳转,使用 App Router Route Handlers。
+
## 配置
复制 .env.example 为 .env.local,按实际部署填写:
@@ -48,9 +56,11 @@ npm run build
node tests/api-smoke.mjs
```
-API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、令牌不泄漏、同源校验和路径白名单,不访问线上账号。构建需先完成。浏览器验收覆盖登录/注册切换、演示画布创建、搜索空状态、笔记保存、桌面与 390px 手机布局。
+API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、令牌不泄漏、同源校验和路径白名单,不访问线上账号。构建需先完成。冒烟测试还覆盖所有页面地址、元信息、OAuth 跳转、请求体限制及退出会话。此前的浏览器验收覆盖登录/注册切换、演示画布创建、搜索空状态、笔记保存、桌面与 390px 手机布局;本次迁移的验证结果见 VALIDATION.md。
-项目尚未创建远程仓库或部署。
+若执行环境限制 Turbopack 的 CSS 工作进程绑定本地端口,可使用官方 Webpack 模式:`npm run build -- --webpack`。
+
+项目已同步至 `ssh://git@gitea.entroia.com:22222/Zayden-Jung/endoleap.git`;尚未部署。
性能分析、实测耗时和修复说明见 [PERFORMANCE.md](PERFORMANCE.md)。
diff --git a/VALIDATION.md b/VALIDATION.md
index 93c9dff..f74d88f 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -16,3 +16,15 @@
## 真实账号创建验收
已在用户已登录的 Chrome 中打开 http://localhost:17321/dashboard。初始空间列表为空;使用新建画布流程提交用户原先填写的「周末的灵感」后,创建了「个人空间」和该画布。完整刷新页面后,侧栏和主列表仍显示该画布,确认读取到后端持久化记录。此项为真实账号验收,补充此前只完成隔离模拟测试的限制。
+
+## App Router 迁移验收(2026-11-05)
+
+- 全部页面迁入 `src/app`;根布局提供全局样式、`zh-CN`、标题及描述。工作台通过 `next/navigation` 读取演示参数,入口提供 Suspense 边界。
+- 两个 API 改为 Route Handlers,保留会话 Cookie、Origin 校验、路径和查询参数白名单、15 秒超时、请求取消及 Server-Timing;显式保留 1 MiB 请求体限制。
+- `npm run build -- --webpack`:通过,构建结果全部为 App Router 路由。
+- 默认 `npm run build`:当前环境禁止 Turbopack CSS 工作进程绑定本地端口而失败;没有更改项目默认构建器。清理迁移前 `.next` 缓存后,Webpack 编译、类型检查和静态生成均通过。
+- `npm run typecheck`:通过。
+- `node --experimental-strip-types --test tests/workspace.test.mjs`:4 项通过。
+- `node tests/api-smoke.mjs`:通过。覆盖首页、登录、登录回调、真实及演示工作台的 HTTP 响应、语言/标题/描述/viewport、404、Cookie 与令牌隔离、HTTPS Secure Cookie、登录态代理、跨站及非法 Origin、路径及查询白名单、请求方法、非法 JSON、超限请求体、Google/Slack 302、无效 challenge 和 204 退出清除 Cookie。
+- 本次未重跑浏览器交互与响应式验收:浏览器连接不可用;未进行真人账户登录或部署验证。
+- 迁移前 Pages Router 快照已先推送到现有仓库 main;本次迁移按用户明确授权直接更新 main,不创建 PR。
diff --git a/next-env.d.ts b/next-env.d.ts
index 15849f6..ce4e94a 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -4,4 +4,4 @@ import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
-// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/src/app/api/foundry/[...path]/route.ts b/src/app/api/foundry/[...path]/route.ts
new file mode 100644
index 0000000..8318218
--- /dev/null
+++ b/src/app/api/foundry/[...path]/route.ts
@@ -0,0 +1,122 @@
+import { NextRequest, NextResponse } from "next/server";
+
+const allowed =
+ /^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/;
+const bodyLimit = 1024 * 1024;
+
+// Route Handlers do not have the Pages Router bodyParser size limit.
+async function readBody(req: NextRequest) {
+ const reader = req.body?.getReader();
+ if (!reader) return {};
+ const chunks: Uint8Array[] = [];
+ let size = 0;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > bodyLimit) {
+ await reader.cancel();
+ throw new Error("body_too_large");
+ }
+ chunks.push(value);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ if (!size) return {};
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+}
+
+async function handler(
+ req: NextRequest,
+ { params }: { params: Promise<{ path: string[] }> },
+) {
+ const responseHeaders = new Headers({ "Cache-Control": "no-store" });
+ const json = (body: unknown, status: number) =>
+ NextResponse.json(body, { status, headers: responseHeaders });
+ const requestOrigin = req.headers.get("origin");
+ if (req.method !== "GET" && requestOrigin) {
+ try {
+ if (new URL(requestOrigin).host !== req.headers.get("host"))
+ return json({ error: "invalid_origin" }, 403);
+ } catch {
+ return json({ error: "invalid_origin" }, 403);
+ }
+ }
+ const path = (await params).path.join("/");
+ if (!allowed.test(path)) return json({ error: "not_found" }, 404);
+ let requestBody: unknown;
+ if (req.method !== "GET") {
+ try {
+ requestBody = await readBody(req);
+ } catch (error) {
+ const tooLarge = error instanceof Error && error.message === "body_too_large";
+ return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
+ }
+ }
+ const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
+ const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
+ const headers: Record = { "Content-Type": "application/json" };
+ const session = req.cookies.get("foundry_session")?.value;
+ if (session) headers.Authorization = "Bearer " + session;
+ for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"]) {
+ const value = req.headers.get(h);
+ if (value !== null) headers[h] = value;
+ }
+ const query = new URLSearchParams();
+ for (const key of ["kind", "sort", "q", "limit", "offset"]) {
+ const values = req.nextUrl.searchParams.getAll(key);
+ if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
+ }
+ const started = performance.now();
+ try {
+ const upstream = await fetch(
+ origin + "/projects/" + encodeURIComponent(project) + "/" + path +
+ (query.size ? "?" + query.toString() : ""),
+ {
+ method: req.method,
+ headers,
+ body: req.method === "GET" ? undefined : JSON.stringify(requestBody || {}),
+ signal: AbortSignal.any([AbortSignal.timeout(15000), req.signal]),
+ redirect: "manual",
+ cache: "no-store",
+ },
+ );
+ const headersReceived = performance.now();
+ const body = upstream.status === 204 ? null : await upstream.json();
+ responseHeaders.set(
+ "Server-Timing",
+ `upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
+ );
+ if (body?.access_token) {
+ responseHeaders.set(
+ "Set-Cookie",
+ `foundry_session=${encodeURIComponent(body.access_token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https" ? "; Secure" : ""}`,
+ );
+ delete body.access_token;
+ }
+ if (path === "auth/logout" && upstream.ok)
+ responseHeaders.set(
+ "Set-Cookie",
+ "foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
+ );
+ if (upstream.status === 204)
+ return new NextResponse(null, { status: 204, headers: responseHeaders });
+ return json(body || { error: "upstream_unavailable" }, upstream.status);
+ } catch (error) {
+ responseHeaders.set("Server-Timing", `total;dur=${(performance.now() - started).toFixed(1)}`);
+ const timeout = error instanceof Error && error.name === "TimeoutError";
+ return json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" }, timeout ? 504 : 502);
+ }
+}
+
+function methodNotAllowed() {
+ return NextResponse.json({ error: "method_not_allowed" }, {
+ status: 405,
+ headers: { "Cache-Control": "no-store", Allow: "GET, POST, PATCH, DELETE" },
+ });
+}
+
+export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };
+export { methodNotAllowed as PUT, methodNotAllowed as HEAD, methodNotAllowed as OPTIONS };
diff --git a/src/app/api/oauth/[provider]/route.ts b/src/app/api/oauth/[provider]/route.ts
new file mode 100644
index 0000000..5ddf5c8
--- /dev/null
+++ b/src/app/api/oauth/[provider]/route.ts
@@ -0,0 +1,22 @@
+import { NextRequest, NextResponse } from "next/server";
+
+export async function GET(
+ req: NextRequest,
+ { params }: { params: Promise<{ provider: string }> },
+) {
+ const { provider } = await params;
+ const challenges = req.nextUrl.searchParams.getAll("challenge");
+ const challenge = challenges[0];
+ if (
+ !["google", "slack"].includes(provider) ||
+ challenges.length !== 1 ||
+ !/^[-_a-zA-Z0-9]{43}$/.test(challenge)
+ )
+ return NextResponse.json({ error: "invalid_request" }, { status: 400 });
+ const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
+ const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
+ return NextResponse.redirect(
+ origin + "/projects/" + encodeURIComponent(project) + "/auth/" + provider + "?challenge=" + challenge,
+ { status: 302, headers: { "Cache-Control": "no-store" } },
+ );
+}
diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx
new file mode 100644
index 0000000..a32805d
--- /dev/null
+++ b/src/app/auth/callback/page.tsx
@@ -0,0 +1 @@
+export { default } from "../../login/page";
diff --git a/src/pages/dashboard.tsx b/src/app/dashboard/dashboard.tsx
similarity index 99%
rename from src/pages/dashboard.tsx
rename to src/app/dashboard/dashboard.tsx
index f8ebeae..5e53f1c 100644
--- a/src/pages/dashboard.tsx
+++ b/src/app/dashboard/dashboard.tsx
@@ -1,5 +1,7 @@
+"use client";
+
import { useEffect, useRef, useState, type FormEvent } from "react";
-import { useRouter } from "next/router";
+import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
ArrowUpRight,
@@ -68,7 +70,7 @@ const initialBoards: Board[] = [
];
export default function Dashboard() {
const router = useRouter();
- const demo = router.query.demo === "1";
+ const demo = useSearchParams().get("demo") === "1";
const mutationVersion = useRef(0);
const [boardsLoading, setBoardsLoading] = useState(false);
const [spacesLoaded, setSpacesLoaded] = useState(false);
@@ -93,7 +95,6 @@ export default function Dashboard() {
[nodeText, setNodeText] = useState(""),
[note, setNote] = useState("");
useEffect(() => {
- if (!router.isReady) return;
let active = true;
if (demo) {
setProfile({
@@ -137,7 +138,7 @@ export default function Dashboard() {
active = false;
controller.abort();
};
- }, [router.isReady, demo, router]);
+ }, [demo, router]);
useEffect(() => {
if (demo && ready)
localStorage.setItem(
@@ -245,7 +246,7 @@ export default function Dashboard() {
async function logout() {
try {
if (!demo) await api("auth/logout", { method: "POST" });
- await router.push("/login");
+ router.push("/login");
} catch (e) {
setError(errorText(e));
}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
new file mode 100644
index 0000000..9dc854f
--- /dev/null
+++ b/src/app/dashboard/page.tsx
@@ -0,0 +1,16 @@
+import { Suspense } from "react";
+import { Brand } from "@/components/brand";
+import Dashboard from "./dashboard";
+
+export default function DashboardPage() {
+ return (
+
+
+ 正在打开你的创作空间…
+
+ }>
+
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..1933fe9
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+import "@/styles/globals.css";
+
+export const metadata: Metadata = {
+ title: "啡跃 Endoleap · 让灵感有形",
+ description: "收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。",
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/pages/login.tsx b/src/app/login/page.tsx
similarity index 98%
rename from src/pages/login.tsx
rename to src/app/login/page.tsx
index dd98654..b40625b 100644
--- a/src/pages/login.tsx
+++ b/src/app/login/page.tsx
@@ -1,6 +1,8 @@
+"use client";
+
import { useEffect, useState, type FormEvent } from "react";
import Link from "next/link";
-import { useRouter } from "next/router";
+import { useRouter } from "next/navigation";
import {
ArrowRight,
Eye,
@@ -65,7 +67,7 @@ export default function Login() {
});
if (register)
setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。");
- else await router.push("/dashboard");
+ else router.push("/dashboard");
} catch (e) {
setError(errorText(e));
} finally {
diff --git a/src/pages/index.tsx b/src/app/page.tsx
similarity index 99%
rename from src/pages/index.tsx
rename to src/app/page.tsx
index bb4f8e0..4b951c9 100644
--- a/src/pages/index.tsx
+++ b/src/app/page.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import Link from "next/link";
import { useState } from "react";
import { ArrowUpRight, ArrowRight, Plus, Minus } from "lucide-react";
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
deleted file mode 100644
index b45f7ef..0000000
--- a/src/pages/_app.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { AppProps } from "next/app";
-import Head from "next/head";
-import "@/styles/globals.css";
-export default function App({ Component, pageProps }: AppProps) {
- return (
- <>
-
- 啡跃 Endoleap · 让灵感有形
-
-
-
-
- >
- );
-}
diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx
deleted file mode 100644
index 531fba1..0000000
--- a/src/pages/_document.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Html, Head, Main, NextScript } from "next/document";
-export default function Document() {
- return (
-
-
-
-
-
-
-
- );
-}
diff --git a/src/pages/api/foundry/[...path].ts b/src/pages/api/foundry/[...path].ts
deleted file mode 100644
index a6630ab..0000000
--- a/src/pages/api/foundry/[...path].ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import type { NextApiRequest, NextApiResponse } from "next";
-const allowed =
- /^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/;
-export default async function handler(
- req: NextApiRequest,
- res: NextApiResponse,
-) {
- res.setHeader("Cache-Control", "no-store");
- if (!["GET", "POST", "PATCH", "DELETE"].includes(req.method || ""))
- return res.status(405).json({ error: "method_not_allowed" });
- if (req.method !== "GET" && req.headers.origin) {
- try {
- if (new URL(req.headers.origin).host !== req.headers.host)
- return res.status(403).json({ error: "invalid_origin" });
- } catch {
- return res.status(403).json({ error: "invalid_origin" });
- }
- }
- const path = (Array.isArray(req.query.path) ? req.query.path : []).join("/");
- if (!allowed.test(path)) return res.status(404).json({ error: "not_found" });
- const origin =
- process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
- const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
- const headers: Record = {
- "Content-Type": "application/json",
- };
- if (req.cookies.foundry_session)
- headers.Authorization = "Bearer " + req.cookies.foundry_session;
- for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"])
- if (typeof req.headers[h] === "string")
- headers[h] = req.headers[h] as string;
- const query = new URLSearchParams();
- for (const key of ["kind", "sort", "q", "limit", "offset"]) {
- const v = req.query[key];
- if (typeof v === "string" && v.length < 300) query.set(key, v);
- }
- const started = performance.now();
- const disconnected = new AbortController();
- const onClose = () => {
- if (!res.writableEnded) disconnected.abort();
- };
- res.once("close", onClose);
- try {
- const upstream = await fetch(
- origin +
- "/projects/" +
- encodeURIComponent(project) +
- "/" +
- path +
- (query.size ? "?" + query.toString() : ""),
- {
- method: req.method,
- headers,
- body: req.method === "GET" ? undefined : JSON.stringify(req.body || {}),
- signal: AbortSignal.any([
- AbortSignal.timeout(15000),
- disconnected.signal,
- ]),
- redirect: "manual",
- },
- );
- const headersReceived = performance.now();
- const body = upstream.status === 204 ? null : await upstream.json();
- res.setHeader(
- "Server-Timing",
- `upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
- );
- const session = body?.access_token;
- if (session) {
- res.setHeader(
- "Set-Cookie",
- `foundry_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers["x-forwarded-proto"] === "https" ? "; Secure" : ""}`,
- );
- delete body.access_token;
- }
- if (path === "auth/logout" && upstream.ok)
- res.setHeader(
- "Set-Cookie",
- "foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
- );
- if (upstream.status === 204) return res.status(204).end();
- return res
- .status(upstream.status)
- .json(body || { error: "upstream_unavailable" });
- } catch (error) {
- if (res.destroyed) return;
- res.setHeader(
- "Server-Timing",
- `total;dur=${(performance.now() - started).toFixed(1)}`,
- );
- const timeout = error instanceof Error && error.name === "TimeoutError";
- return res
- .status(timeout ? 504 : 502)
- .json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" });
- } finally {
- res.off("close", onClose);
- }
-}
-export const config = { api: { bodyParser: { sizeLimit: "1mb" } } };
diff --git a/src/pages/api/oauth/[provider].ts b/src/pages/api/oauth/[provider].ts
deleted file mode 100644
index 49a503a..0000000
--- a/src/pages/api/oauth/[provider].ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { NextApiRequest, NextApiResponse } from "next";
-export default function handler(req: NextApiRequest, res: NextApiResponse) {
- const p = req.query.provider,
- c = req.query.challenge;
- if (
- req.method !== "GET" ||
- !["google", "slack"].includes(String(p)) ||
- typeof c !== "string" ||
- !/^[-_a-zA-Z0-9]{43}$/.test(c)
- )
- return res.status(400).json({ error: "invalid_request" });
- const origin =
- process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
- const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
- res.setHeader("Cache-Control", "no-store");
- res.redirect(
- 302,
- origin +
- "/projects/" +
- encodeURIComponent(project) +
- "/auth/" +
- p +
- "?challenge=" +
- c,
- );
-}
diff --git a/src/pages/auth/callback.tsx b/src/pages/auth/callback.tsx
deleted file mode 100644
index b88e0c4..0000000
--- a/src/pages/auth/callback.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "../login";
diff --git a/tests/api-smoke.mjs b/tests/api-smoke.mjs
index 0ed0b85..b9d3bd5 100644
--- a/tests/api-smoke.mjs
+++ b/tests/api-smoke.mjs
@@ -6,7 +6,12 @@ import { fileURLToPath } from "node:url";
const host = "http://127.0.0.1:3005";
const upstream = createServer((req, res) => {
res.setHeader("Content-Type", "application/json");
- if (req.url.endsWith("/auth/login")) {
+ if (req.url.endsWith("/auth/logout")) {
+ res.statusCode = 204;
+ res.end();
+ } else if (req.url.includes("/workspaces/space/materials")) {
+ res.end(JSON.stringify({ url: req.url, method: req.method }));
+ } else if (req.url.endsWith("/auth/login")) {
res.end(
JSON.stringify({
access_token: "test-only-session",
@@ -64,6 +69,16 @@ try {
await new Promise((r) => setTimeout(r, 100));
}
assert(ready, "Frontend did not start");
+ for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1"]) {
+ const page = await fetch(host + path);
+ assert.equal(page.status, 200, path);
+ const html = await page.text();
+ assert.match(html, /