diff --git a/apps/web/src/app/api/auth/login/route.ts b/apps/web/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..12e95bd6 --- /dev/null +++ b/apps/web/src/app/api/auth/login/route.ts @@ -0,0 +1,11 @@ +import { apiBaseUrlFromEnv, buildLoginProxyRequest, proxyLoginAndSetSessionCookie } from "../../workbench/proxy"; + +// POST /api/auth/login:原生表单提交 → 转发后端 auth/login,成功写 HttpOnly 会话 cookie 并 303 跳工作台; +// 失败 303 回登录页并带 ?error=invalid 提示。 +export async function POST(request: Request): Promise { + const built = await buildLoginProxyRequest({ apiBaseUrl: apiBaseUrlFromEnv(), request }); + return proxyLoginAndSetSessionCookie(built, { + failureLocation: "/login?error=invalid", + successLocation: "/workbench" + }); +} diff --git a/apps/web/src/app/api/auth/logout/route.ts b/apps/web/src/app/api/auth/logout/route.ts new file mode 100644 index 00000000..8fef06c6 --- /dev/null +++ b/apps/web/src/app/api/auth/logout/route.ts @@ -0,0 +1,7 @@ +import { apiBaseUrlFromEnv, buildLogoutProxyRequest, proxyLogoutAndClearSessionCookie } from "../../workbench/proxy"; + +// POST /api/auth/logout:尽力调用后端 auth/logout,幂等清除会话 cookie 并 303 跳登录页。 +export async function POST(request: Request): Promise { + const built = buildLogoutProxyRequest({ apiBaseUrl: apiBaseUrlFromEnv(), request }); + return proxyLogoutAndClearSessionCookie(built, { redirectLocation: "/login" }); +} diff --git a/apps/web/src/app/api/auth/me/route.ts b/apps/web/src/app/api/auth/me/route.ts new file mode 100644 index 00000000..3a54fcf3 --- /dev/null +++ b/apps/web/src/app/api/auth/me/route.ts @@ -0,0 +1,6 @@ +import { apiBaseUrlFromEnv, buildMeProxyRequest, proxyBuiltRequest } from "../../workbench/proxy"; + +// GET /api/auth/me:携带会话 cookie 读取当前用户/角色,供应用外壳展示;无 token 时 fail-closed 401。 +export async function GET(request: Request): Promise { + return proxyBuiltRequest(buildMeProxyRequest({ apiBaseUrl: apiBaseUrlFromEnv(), request })); +} diff --git a/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/compile-game-ir/route.ts b/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/compile-game-ir/route.ts index 1e4ae584..d1bbe512 100644 --- a/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/compile-game-ir/route.ts +++ b/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/compile-game-ir/route.ts @@ -1,4 +1,4 @@ -import { apiBaseUrlFromEnv, buildCompileGameIRProxyRequest, proxyBuiltRequest } from "../../../../../proxy"; +import { apiBaseUrlFromEnv, buildCompileGameIRProxyRequest, proxyFormPostAndRedirect } from "../../../../../proxy"; type RouteContext = { readonly params: Promise<{ @@ -7,14 +7,13 @@ type RouteContext = { }>; }; +// POST:编译 GameIR。原生表单提交 → 后端编译校验 → PRG 重定向回工作台(带 ?compiled 标记);失败带 ?error=compile。 +// 注意 S2 边界:编译止于 validated GameIR,工作台不承诺「可玩/预览」。 export async function POST(request: Request, context: RouteContext): Promise { const { projectId, versionId } = await context.params; - return proxyBuiltRequest( - await buildCompileGameIRProxyRequest({ - apiBaseUrl: apiBaseUrlFromEnv(), - projectId, - request, - versionId - }) + const workbench = `/workbench/${encodeURIComponent(projectId)}`; + return proxyFormPostAndRedirect( + await buildCompileGameIRProxyRequest({ apiBaseUrl: apiBaseUrlFromEnv(), projectId, request, versionId }), + { failureLocation: `${workbench}?error=compile`, successLocation: `${workbench}?compiled=ok` } ); } diff --git a/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/creation-agent/messages/route.ts b/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/creation-agent/messages/route.ts index a0795221..61eebba5 100644 --- a/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/creation-agent/messages/route.ts +++ b/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/creation-agent/messages/route.ts @@ -1,4 +1,4 @@ -import { apiBaseUrlFromEnv, buildCreationAgentPromptProxyRequest, proxyBuiltRequest } from "../../../../../../proxy"; +import { apiBaseUrlFromEnv, buildCreationAgentPromptProxyRequest, proxyFormPostAndRedirect } from "../../../../../../proxy"; type RouteContext = { readonly params: Promise<{ @@ -7,14 +7,13 @@ type RouteContext = { }>; }; +// POST:prompt 生成。原生表单提交 → 后端确定性生成器产出草稿 → PRG 重定向回工作台(带 ?generated 标记), +// 页面 reload 后通过 loadWorkbenchProjectData 展示新草稿;失败带 ?error=generate。 export async function POST(request: Request, context: RouteContext): Promise { const { projectId, versionId } = await context.params; - return proxyBuiltRequest( - await buildCreationAgentPromptProxyRequest({ - apiBaseUrl: apiBaseUrlFromEnv(), - projectId, - request, - versionId - }) + const workbench = `/workbench/${encodeURIComponent(projectId)}`; + return proxyFormPostAndRedirect( + await buildCreationAgentPromptProxyRequest({ apiBaseUrl: apiBaseUrlFromEnv(), projectId, request, versionId }), + { failureLocation: `${workbench}?error=generate`, successLocation: `${workbench}?generated=ok` } ); } diff --git a/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/creation-agent/sections/route.ts b/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/creation-agent/sections/route.ts index 3014cd28..7cf49749 100644 --- a/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/creation-agent/sections/route.ts +++ b/apps/web/src/app/api/workbench/projects/[projectId]/versions/[versionId]/creation-agent/sections/route.ts @@ -1,4 +1,4 @@ -import { apiBaseUrlFromEnv, buildSaveSectionProxyRequest, proxyBuiltRequest } from "../../../../../../proxy"; +import { apiBaseUrlFromEnv, buildSaveSectionProxyRequest, proxyFormPostAndRedirect } from "../../../../../../proxy"; type RouteContext = { readonly params: Promise<{ @@ -7,14 +7,12 @@ type RouteContext = { }>; }; +// POST:分节保存(config_edit)。原生表单提交 → 后端写回分节 → PRG 重定向回工作台(带 ?saved 标记);失败带 ?error=save。 export async function POST(request: Request, context: RouteContext): Promise { const { projectId, versionId } = await context.params; - return proxyBuiltRequest( - await buildSaveSectionProxyRequest({ - apiBaseUrl: apiBaseUrlFromEnv(), - projectId, - request, - versionId - }) + const workbench = `/workbench/${encodeURIComponent(projectId)}`; + return proxyFormPostAndRedirect( + await buildSaveSectionProxyRequest({ apiBaseUrl: apiBaseUrlFromEnv(), projectId, request, versionId }), + { failureLocation: `${workbench}?error=save`, successLocation: `${workbench}?saved=ok` } ); } diff --git a/apps/web/src/app/api/workbench/projects/[projectId]/versions/route.ts b/apps/web/src/app/api/workbench/projects/[projectId]/versions/route.ts index 184b1671..45396666 100644 --- a/apps/web/src/app/api/workbench/projects/[projectId]/versions/route.ts +++ b/apps/web/src/app/api/workbench/projects/[projectId]/versions/route.ts @@ -1,4 +1,4 @@ -import { apiBaseUrlFromEnv, buildListProjectVersionsProxyRequest, proxyBuiltRequest } from "../../../proxy"; +import { apiBaseUrlFromEnv, buildListProjectVersionsProxyRequest, createDraftVersionAndRedirect, proxyBuiltRequest } from "../../../proxy"; type RouteContext = { readonly params: Promise<{ @@ -16,3 +16,13 @@ export async function GET(request: Request, context: RouteContext): Promise { + const { projectId } = await context.params; + const projectLocation = `/workbench/${encodeURIComponent(projectId)}`; + return createDraftVersionAndRedirect( + { apiBaseUrl: apiBaseUrlFromEnv(), projectId, request }, + { failureLocation: `${projectLocation}?error=version`, successLocation: projectLocation } + ); +} diff --git a/apps/web/src/app/api/workbench/projects/route.ts b/apps/web/src/app/api/workbench/projects/route.ts new file mode 100644 index 00000000..42f8319b --- /dev/null +++ b/apps/web/src/app/api/workbench/projects/route.ts @@ -0,0 +1,13 @@ +import { apiBaseUrlFromEnv, createProjectThenVersionAndRedirect } from "../proxy"; + +// POST /api/workbench/projects:原生表单提交 → 创建项目 + 自动建首个 draft version → 303 跳项目工作台。 +// 失败回创建页并带 ?error=create 提示。 +export async function POST(request: Request): Promise { + return createProjectThenVersionAndRedirect( + { apiBaseUrl: apiBaseUrlFromEnv(), request }, + { + failureLocation: "/workbench/new?error=create", + projectLocation: (projectId) => `/workbench/${encodeURIComponent(projectId)}` + } + ); +} diff --git a/apps/web/src/app/api/workbench/proxy.test.ts b/apps/web/src/app/api/workbench/proxy.test.ts index d6c612af..d544eb15 100644 --- a/apps/web/src/app/api/workbench/proxy.test.ts +++ b/apps/web/src/app/api/workbench/proxy.test.ts @@ -5,8 +5,17 @@ import { buildCreationAgentSessionReadProxyRequest, buildCreationAgentPromptProxyRequest, buildDesignSectionsReadProxyRequest, + buildCreateDraftVersionProxyRequest, + buildCreateProjectProxyRequest, buildListProjectVersionsProxyRequest, - buildSaveSectionProxyRequest + buildLoginProxyRequest, + buildLogoutProxyRequest, + buildMeProxyRequest, + buildSaveSectionProxyRequest, + extractCreatedProjectId, + extractLoginResult, + serializeClearedSessionCookie, + serializeSessionCookie } from "./proxy"; describe("S2 workbench Next API proxy helpers", () => { @@ -167,3 +176,200 @@ describe("S2 workbench Next API proxy helpers", () => { }); }); }); + +describe("M0-T1 auth Next API proxy helpers", () => { + it("builds an auth/login POST from an email form without requiring a bearer token", async () => { + const request = new Request("http://localhost/api/auth/login", { + body: new URLSearchParams({ email: " creator@example.test " }), + method: "POST" + }); + + const proxied = await buildLoginProxyRequest({ apiBaseUrl: "http://api.test/", request }); + + expect(proxied).toEqual({ + ok: true, + body: { email: "creator@example.test" }, + headers: { "content-type": "application/json" }, + method: "POST", + url: "http://api.test/auth/login" + }); + }); + + it("falls back to userId when email is absent", async () => { + const request = new Request("http://localhost/api/auth/login", { + body: new URLSearchParams({ userId: "seed-creator" }), + method: "POST" + }); + + const proxied = await buildLoginProxyRequest({ apiBaseUrl: "http://api.test", request }); + + expect(proxied).toEqual({ + ok: true, + body: { userId: "seed-creator" }, + headers: { "content-type": "application/json" }, + method: "POST", + url: "http://api.test/auth/login" + }); + }); + + it("rejects a login form lacking both email and userId", async () => { + const request = new Request("http://localhost/api/auth/login", { + body: new URLSearchParams({}), + method: "POST" + }); + + const proxied = await buildLoginProxyRequest({ apiBaseUrl: "http://api.test", request }); + + expect(proxied).toEqual({ + ok: false, + status: 400, + body: { code: "INVALID_WORKBENCH_FORM", message: "email or userId is required" } + }); + }); + + it("builds an auth/logout POST with the bearer token from cookie", () => { + const request = new Request("http://localhost/api/auth/logout", { + headers: { cookie: "huijing_api_token=logout-token" }, + method: "POST" + }); + + expect(buildLogoutProxyRequest({ apiBaseUrl: "http://api.test/", request })).toEqual({ + ok: true, + body: {}, + headers: { authorization: "Bearer logout-token", "content-type": "application/json" }, + method: "POST", + url: "http://api.test/auth/logout" + }); + }); + + it("returns 401 for logout when no token is present", () => { + const request = new Request("http://localhost/api/auth/logout", { method: "POST" }); + + expect(buildLogoutProxyRequest({ apiBaseUrl: "http://api.test", request })).toEqual({ + ok: false, + status: 401, + body: { code: "UNAUTHORIZED", message: "API bearer token is required" } + }); + }); + + it("builds a GET /me read with the bearer token, and fails closed without one", () => { + const authed = new Request("http://localhost/api/auth/me", { + headers: { authorization: "Bearer me-token" }, + method: "GET" + }); + expect(buildMeProxyRequest({ apiBaseUrl: "http://api.test/", request: authed })).toEqual({ + headers: { authorization: "Bearer me-token" }, + method: "GET", + ok: true, + url: "http://api.test/me" + }); + + const anonymous = new Request("http://localhost/api/auth/me", { method: "GET" }); + expect(buildMeProxyRequest({ apiBaseUrl: "http://api.test", request: anonymous })).toEqual({ + ok: false, + status: 401, + body: { code: "UNAUTHORIZED", message: "API bearer token is required" } + }); + }); + + it("serializes an HttpOnly SameSite=Lax session cookie, with Secure only in production", () => { + const dev = serializeSessionCookie("abc.def", { NODE_ENV: "development" } as NodeJS.ProcessEnv); + expect(dev).toBe("huijing_api_token=abc.def; Path=/; HttpOnly; SameSite=Lax"); + + const prod = serializeSessionCookie("abc.def", { NODE_ENV: "production" } as NodeJS.ProcessEnv); + expect(prod).toBe("huijing_api_token=abc.def; Path=/; HttpOnly; SameSite=Lax; Secure"); + }); + + it("serializes a cleared session cookie with Max-Age=0", () => { + expect(serializeClearedSessionCookie({ NODE_ENV: "development" } as NodeJS.ProcessEnv)).toBe( + "huijing_api_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + ); + }); + + it("extracts token and actor from a login response, and rejects malformed bodies", () => { + expect(extractLoginResult(JSON.stringify({ token: "t1", actor: { id: "seed-creator" } }))).toEqual({ + token: "t1", + actor: { id: "seed-creator" } + }); + expect(extractLoginResult(JSON.stringify({ actor: { id: "x" } }))).toBeNull(); + expect(extractLoginResult("not-json")).toBeNull(); + }); +}); + +describe("M0-T4 projects Next API proxy helpers", () => { + it("builds a create-project POST from a title form with the cookie bearer token", async () => { + const request = new Request("http://localhost/api/workbench/projects", { + body: new URLSearchParams({ title: " 猫咖模拟经营 " }), + headers: { cookie: "huijing_api_token=create-token" }, + method: "POST" + }); + + expect(await buildCreateProjectProxyRequest({ apiBaseUrl: "http://api.test/", request })).toEqual({ + ok: true, + body: { title: "猫咖模拟经营" }, + headers: { authorization: "Bearer create-token", "content-type": "application/json" }, + method: "POST", + url: "http://api.test/projects" + }); + }); + + it("includes an optional slug when provided", async () => { + const request = new Request("http://localhost/api/workbench/projects", { + body: new URLSearchParams({ slug: "cat-cafe", title: "猫咖" }), + headers: { authorization: "Bearer create-token" }, + method: "POST" + }); + + expect(await buildCreateProjectProxyRequest({ apiBaseUrl: "http://api.test", request })).toEqual({ + ok: true, + body: { slug: "cat-cafe", title: "猫咖" }, + headers: { authorization: "Bearer create-token", "content-type": "application/json" }, + method: "POST", + url: "http://api.test/projects" + }); + }); + + it("rejects create-project without a title and fails closed without a token", async () => { + const noTitle = new Request("http://localhost/api/workbench/projects", { + body: new URLSearchParams({}), + headers: { cookie: "huijing_api_token=create-token" }, + method: "POST" + }); + expect(await buildCreateProjectProxyRequest({ apiBaseUrl: "http://api.test", request: noTitle })).toEqual({ + ok: false, + status: 400, + body: { code: "INVALID_WORKBENCH_FORM", message: "title is required" } + }); + + const noToken = new Request("http://localhost/api/workbench/projects", { + body: new URLSearchParams({ title: "猫咖" }), + method: "POST" + }); + expect(await buildCreateProjectProxyRequest({ apiBaseUrl: "http://api.test", request: noToken })).toEqual({ + ok: false, + status: 401, + body: { code: "UNAUTHORIZED", message: "API bearer token is required" } + }); + }); + + it("builds a create-draft-version POST with an empty configJson", () => { + const request = new Request("http://localhost/api/workbench/projects/project-a/versions", { + headers: { cookie: "huijing_api_token=version-token" }, + method: "POST" + }); + + expect(buildCreateDraftVersionProxyRequest({ apiBaseUrl: "http://api.test/", projectId: "project-a", request })).toEqual({ + ok: true, + body: { configJson: {} }, + headers: { authorization: "Bearer version-token", "content-type": "application/json" }, + method: "POST", + url: "http://api.test/projects/project-a/versions" + }); + }); + + it("extracts the created project id, and rejects malformed bodies", () => { + expect(extractCreatedProjectId(JSON.stringify({ id: "proj-1", title: "x" }))).toBe("proj-1"); + expect(extractCreatedProjectId(JSON.stringify({ title: "x" }))).toBeNull(); + expect(extractCreatedProjectId("not-json")).toBeNull(); + }); +}); diff --git a/apps/web/src/app/api/workbench/proxy.ts b/apps/web/src/app/api/workbench/proxy.ts index 2f5a65e4..8baa166e 100644 --- a/apps/web/src/app/api/workbench/proxy.ts +++ b/apps/web/src/app/api/workbench/proxy.ts @@ -9,6 +9,15 @@ type ProxyBuildInput = { type ProjectProxyBuildInput = Omit; +// auth 代理只需要 API base 与原始请求(token 从 cookie/Authorization 头取,无需 project/version 作用域)。 +type AuthProxyBuildInput = { + readonly apiBaseUrl: string; + readonly request: Request; +}; + +// 会话 cookie 名称:proxy 已按此名从 cookie 提取 Bearer token(见 bearerTokenFrom)。 +export const SESSION_COOKIE_NAME = "huijing_api_token"; + type WorkbenchSaveSectionPayload = { readonly baseChecksum: string; readonly patchJson: Record; @@ -19,7 +28,8 @@ export type BuiltApiProxyRequest = | { readonly ok: true; readonly body: Record; - readonly headers: Record<"authorization" | "content-type", string>; + // authorization 为可选:登录(login)是获取 token 的入口,本身不需要 Bearer。 + readonly headers: { readonly "content-type": string; readonly authorization?: string }; readonly method: "POST"; readonly url: string; } @@ -91,6 +101,258 @@ export async function buildCompileGameIRProxyRequest(input: ProxyBuildInput): Pr return apiProxyRequest(input, "compile-game-ir", token, {}); } +// ============ M0-T1:auth 代理(login / logout / me)+ 会话 cookie ============ + +// 登录:在 4 个 seeded 测试用户中按 email 或 userId 匹配,无需 Bearer token。 +export async function buildLoginProxyRequest(input: AuthProxyBuildInput): Promise { + const form = await input.request.formData(); + const email = stringFormValue(form, "email"); + const userId = stringFormValue(form, "userId"); + if (!email && !userId) return badRequest("email or userId is required"); + + return { + ok: true, + // 后端 login 接受 { email } 或 { userId };email 优先。 + body: email ? { email } : { userId: userId as string }, + headers: { "content-type": "application/json" }, + method: "POST", + url: `${normalizeBaseUrl(input.apiBaseUrl)}/auth/login` + }; +} + +// 登出:携带当前 token 调用后端 auth/logout;无 token 时 fail-closed 返回 401。 +export function buildLogoutProxyRequest(input: AuthProxyBuildInput): BuiltApiProxyRequest { + const token = bearerTokenFrom(input.request); + if (!token) return unauthorized(); + return { + ok: true, + body: {}, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json" + }, + method: "POST", + url: `${normalizeBaseUrl(input.apiBaseUrl)}/auth/logout` + }; +} + +// 当前用户:携带 token 读取后端 GET /me,供应用外壳展示用户/角色。 +export function buildMeProxyRequest(input: AuthProxyBuildInput): BuiltApiProxyRequest { + const token = bearerTokenFrom(input.request); + if (!token) return unauthorized(); + return { + headers: { authorization: `Bearer ${token}` }, + method: "GET", + ok: true, + url: `${normalizeBaseUrl(input.apiBaseUrl)}/me` + }; +} + +// 写会话 cookie:HttpOnly(前端 JS 读不到 token)+ SameSite=Lax + Path=/;生产环境加 Secure。 +export function serializeSessionCookie(token: string, env: NodeJS.ProcessEnv = process.env): string { + const secure = env.NODE_ENV === "production" ? "; Secure" : ""; + return `${SESSION_COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax${secure}`; +} + +// 清会话 cookie:Max-Age=0 立即失效,用于登出。 +export function serializeClearedSessionCookie(env: NodeJS.ProcessEnv = process.env): string { + const secure = env.NODE_ENV === "production" ? "; Secure" : ""; + return `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`; +} + +// 从后端 login 响应体提取 { token, actor };解析失败或缺 token 返回 null。 +export function extractLoginResult(responseBodyText: string): { readonly token: string; readonly actor: unknown } | null { + try { + const parsed = JSON.parse(responseBodyText) as unknown; + if (isRecord(parsed) && typeof parsed.token === "string" && parsed.token.trim() !== "") { + return { token: parsed.token, actor: parsed.actor ?? null }; + } + return null; + } catch { + return null; + } +} + +// 303 重定向响应(原生表单提交后 PRG 模式):可选携带 Set-Cookie。 +function redirectResponse(location: string, setCookie?: string): Response { + const headers = new Headers({ location }); + if (setCookie) headers.append("set-cookie", setCookie); + return new Response(null, { headers, status: 303 }); +} + +// 登录执行器:原生表单提交 → 成功写 HttpOnly cookie 并 303 跳成功页;失败 303 跳失败页(带错误标记)。 +// token 只进 HttpOnly cookie,绝不出现在响应体或重定向 URL 中。 +export async function proxyLoginAndSetSessionCookie( + built: BuiltApiProxyRequest, + options: { readonly successLocation: string; readonly failureLocation: string; readonly env?: NodeJS.ProcessEnv } +): Promise { + const env = options.env ?? process.env; + if (!built.ok) return redirectResponse(options.failureLocation); + try { + const response = await fetch(built.url, { + body: JSON.stringify(built.method === "POST" ? built.body : {}), + headers: built.headers, + method: "POST" + }); + const text = await response.text(); + if (response.ok) { + const result = extractLoginResult(text); + if (result) return redirectResponse(options.successLocation, serializeSessionCookie(result.token, env)); + } + } catch { + // 后端不可达:按登录失败处理,跳失败页。 + } + return redirectResponse(options.failureLocation); +} + +// 登出执行器:幂等清 cookie 并 303 跳目标页。有 token 则尽力调用后端登出;后端不可达也要清本地 cookie。 +export async function proxyLogoutAndClearSessionCookie( + built: BuiltApiProxyRequest, + options: { readonly redirectLocation: string; readonly env?: NodeJS.ProcessEnv } +): Promise { + const env = options.env ?? process.env; + if (built.ok) { + try { + await fetch(built.url, { + body: JSON.stringify(built.method === "POST" ? built.body : {}), + headers: built.headers, + method: "POST" + }); + } catch { + // 登出尽力而为:后端失败不阻断本地 cookie 清除。 + } + } + return redirectResponse(options.redirectLocation, serializeClearedSessionCookie(env)); +} + +// ============ M0-T4:projects 代理(创建项目 / 创建首个 draft version)============ + +// 创建项目:creator 角色专属(后端强制);表单需 title,slug 可选。 +export async function buildCreateProjectProxyRequest(input: AuthProxyBuildInput): Promise { + const token = bearerTokenFrom(input.request); + if (!token) return unauthorized(); + const form = await input.request.formData(); + const title = stringFormValue(form, "title"); + if (!title) return badRequest("title is required"); + const slug = stringFormValue(form, "slug"); + return { + ok: true, + body: slug ? { slug, title } : { title }, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json" + }, + method: "POST", + url: `${normalizeBaseUrl(input.apiBaseUrl)}/projects` + }; +} + +// 创建首个 draft version:configJson 留空对象,后端走 StateTransitionService 落 draft。 +export function buildCreateDraftVersionProxyRequest(input: ProjectProxyBuildInput): BuiltApiProxyRequest { + const token = bearerTokenFrom(input.request); + if (!token) return unauthorized(); + return { + ok: true, + body: { configJson: {} }, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json" + }, + method: "POST", + url: `${normalizeBaseUrl(input.apiBaseUrl)}/projects/${encodeURIComponent(input.projectId)}/versions` + }; +} + +// 从创建项目响应体提取项目 id;解析失败返回 null。 +export function extractCreatedProjectId(responseBodyText: string): string | null { + try { + const parsed = JSON.parse(responseBodyText) as unknown; + if (isRecord(parsed) && typeof parsed.id === "string" && parsed.id.trim() !== "") return parsed.id; + return null; + } catch { + return null; + } +} + +// 建项目执行器:创建项目 → 自动建首个 draft version → 303 跳项目工作台。 +// 建版失败不阻断跳转(详情页空状态可引导重试建版),避免留下无法进入的孤儿项目死路。 +export async function createProjectThenVersionAndRedirect( + input: AuthProxyBuildInput, + options: { readonly failureLocation: string; readonly projectLocation: (projectId: string) => string } +): Promise { + const built = await buildCreateProjectProxyRequest(input); + if (!built.ok) return redirectResponse(options.failureLocation); + + let projectId: string | null; + try { + const created = await fetch(built.url, { + body: JSON.stringify(built.method === "POST" ? built.body : {}), + headers: built.headers, + method: "POST" + }); + const createdText = await created.text(); + if (!created.ok) return redirectResponse(options.failureLocation); + projectId = extractCreatedProjectId(createdText); + } catch { + return redirectResponse(options.failureLocation); + } + if (!projectId) return redirectResponse(options.failureLocation); + + const versionBuilt = buildCreateDraftVersionProxyRequest({ apiBaseUrl: input.apiBaseUrl, projectId, request: input.request }); + if (versionBuilt.ok) { + try { + await fetch(versionBuilt.url, { + body: JSON.stringify(versionBuilt.method === "POST" ? versionBuilt.body : {}), + headers: versionBuilt.headers, + method: "POST" + }); + } catch { + // 建版失败不阻断:跳项目页,空状态兜底引导重试。 + } + } + return redirectResponse(options.projectLocation(projectId)); +} + +// 通用「原生表单 POST → PRG 重定向」执行器:用于 prompt 生成 / 分节保存 / 编译, +// 把后端 JSON 透传死胡同改为重定向回工作台并带成功/失败标记,由页面渲染状态横幅。 +export async function proxyFormPostAndRedirect( + built: BuiltApiProxyRequest, + options: { readonly successLocation: string; readonly failureLocation: string } +): Promise { + if (!built.ok) return redirectResponse(options.failureLocation); + try { + const response = await fetch(built.url, { + body: JSON.stringify(built.method === "POST" ? built.body : {}), + headers: built.headers, + method: built.method + }); + if (!response.ok) return redirectResponse(options.failureLocation); + } catch { + return redirectResponse(options.failureLocation); + } + return redirectResponse(options.successLocation); +} + +// 建版执行器(详情页空状态重试用):创建 draft version → 303 跳项目工作台。 +export async function createDraftVersionAndRedirect( + input: ProjectProxyBuildInput, + options: { readonly successLocation: string; readonly failureLocation: string } +): Promise { + const built = buildCreateDraftVersionProxyRequest(input); + if (!built.ok) return redirectResponse(options.failureLocation); + try { + const response = await fetch(built.url, { + body: JSON.stringify(built.method === "POST" ? built.body : {}), + headers: built.headers, + method: "POST" + }); + if (!response.ok) return redirectResponse(options.failureLocation); + } catch { + return redirectResponse(options.failureLocation); + } + return redirectResponse(options.successLocation); +} + export function buildListProjectVersionsProxyRequest(input: ProjectProxyBuildInput): BuiltApiProxyRequest { const token = bearerTokenFrom(input.request); if (!token) return unauthorized(); diff --git a/apps/web/src/app/auth-data.test.ts b/apps/web/src/app/auth-data.test.ts new file mode 100644 index 00000000..80932aa4 --- /dev/null +++ b/apps/web/src/app/auth-data.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadCurrentActor, parseActor, primaryRole } from "./auth-data"; + +describe("loadCurrentActor", () => { + it("returns null without a token without calling the API", async () => { + const fetchImpl = vi.fn(); + expect(await loadCurrentActor({ apiBaseUrl: "http://api.test", apiToken: null, fetchImpl: fetchImpl as unknown as typeof fetch })).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("reads the actor from GET /me with the bearer token", async () => { + const fetchImpl = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ id: "seed-creator", email: "creator@example.test", displayName: "Seed Creator", roles: ["creator"] }), { + headers: { "content-type": "application/json" }, + status: 200 + }) + ) + ); + + const actor = await loadCurrentActor({ apiBaseUrl: "http://api.test/", apiToken: "tok", fetchImpl }); + + expect(actor).toEqual({ id: "seed-creator", email: "creator@example.test", displayName: "Seed Creator", roles: ["creator"] }); + const call = fetchImpl.mock.calls[0]!; + expect(call[0]).toBe("http://api.test/me"); + expect((call[1] as RequestInit | undefined)?.headers).toMatchObject({ authorization: "Bearer tok" }); + }); + + it("degrades to null on non-ok status or network error", async () => { + const failStatus = vi.fn(async () => new Response("forbidden", { status: 403 })); + expect(await loadCurrentActor({ apiBaseUrl: "http://api.test", apiToken: "tok", fetchImpl: failStatus as unknown as typeof fetch })).toBeNull(); + + const throwing = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }); + expect(await loadCurrentActor({ apiBaseUrl: "http://api.test", apiToken: "tok", fetchImpl: throwing as unknown as typeof fetch })).toBeNull(); + }); +}); + +describe("parseActor", () => { + it("accepts a well-formed actor and rejects malformed shapes", () => { + expect(parseActor({ id: "a", email: "a@test", displayName: "A", roles: ["creator", "admin"] })).toEqual({ + id: "a", + email: "a@test", + displayName: "A", + roles: ["creator", "admin"] + }); + expect(parseActor({ id: "a", email: "a@test", displayName: "A", roles: "creator" })).toBeNull(); + expect(parseActor({ id: "", email: "a@test", displayName: "A", roles: [] })).toBeNull(); + expect(parseActor(null)).toBeNull(); + }); +}); + +describe("primaryRole", () => { + it("prefers creator, then operator, then admin, then player", () => { + expect(primaryRole({ id: "a", email: "e", displayName: "n", roles: ["player", "creator"] })).toBe("creator"); + expect(primaryRole({ id: "a", email: "e", displayName: "n", roles: ["player", "operator"] })).toBe("operator"); + expect(primaryRole({ id: "a", email: "e", displayName: "n", roles: ["player"] })).toBe("player"); + expect(primaryRole({ id: "a", email: "e", displayName: "n", roles: ["unknown"] })).toBe("unknown"); + }); +}); diff --git a/apps/web/src/app/auth-data.ts b/apps/web/src/app/auth-data.ts new file mode 100644 index 00000000..6af7f08b --- /dev/null +++ b/apps/web/src/app/auth-data.ts @@ -0,0 +1,74 @@ +// 当前登录用户加载器:供应用外壳(layout)与首页(page)服务端渲染读取用户/角色。 +// 设计为 fail-safe:任何缺失/错误/不可达一律返回 null,使外壳降级为「未登录」而非整页崩溃。 + +type AuthFetch = typeof fetch; + +export type CurrentActor = { + readonly id: string; + readonly email: string; + readonly displayName: string; + readonly roles: readonly string[]; +}; + +type LoadCurrentActorInput = { + readonly apiBaseUrl: string; + readonly apiToken: string | null; + readonly fetchImpl?: AuthFetch; +}; + +export async function loadCurrentActor({ apiBaseUrl, apiToken, fetchImpl = fetch }: LoadCurrentActorInput): Promise { + if (!apiToken) return null; + try { + const response = await fetchImpl(`${normalizeBaseUrl(apiBaseUrl)}/me`, { + cache: "no-store", + headers: { + accept: "application/json", + authorization: `Bearer ${apiToken}` + }, + method: "GET" + }); + if (!response.ok) return null; + return parseActor(await response.json()); + } catch { + // 后端不可达时静默降级为未登录,避免外壳整页报错。 + return null; + } +} + +// 角色优先级:用于在多角色时决定默认入口与展示主角色。 +const ROLE_PRIORITY = ["creator", "operator", "admin", "player"] as const; + +export function primaryRole(actor: CurrentActor): string | null { + for (const role of ROLE_PRIORITY) { + if (actor.roles.includes(role)) return role; + } + return actor.roles[0] ?? null; +} + +export function parseActor(value: unknown): CurrentActor | null { + if (!isRecord(value)) return null; + if (!isNonEmptyString(value.id) || !isNonEmptyString(value.email) || !isNonEmptyString(value.displayName)) { + return null; + } + if (!Array.isArray(value.roles) || !value.roles.every((role) => typeof role === "string")) { + return null; + } + return { + displayName: value.displayName, + email: value.email, + id: value.id, + roles: value.roles + }; +} + +function normalizeBaseUrl(value: string): string { + return value.replace(/\/+$/, ""); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 54424335..4d68ad9f 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,11 +1,99 @@ -import type { ReactNode } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { createElement } from "react"; +import { cookies } from "next/headers"; +import { apiBaseUrlFromEnv, SESSION_COOKIE_NAME } from "./api/workbench/proxy"; +import { loadCurrentActor, primaryRole, type CurrentActor } from "./auth-data"; export const metadata = { description: "Huijing AI app foundation", title: "Huijing AI" }; -export default function RootLayout({ children }: { readonly children: ReactNode }) { - return createElement("html", { lang: "en" }, createElement("body", null, children)); +const styles = { + header: { + alignItems: "center", + background: "#0f3d31", + color: "#eaf3ef", + display: "flex", + fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + gap: 16, + justifyContent: "space-between", + padding: "10px 20px" + }, + brandNav: { alignItems: "center", display: "flex", gap: 18 }, + brand: { color: "#fff", fontSize: 16, fontWeight: 800, textDecoration: "none" }, + navLink: { color: "#cfe3da", fontSize: 14, textDecoration: "none" }, + userBox: { alignItems: "center", display: "flex", fontSize: 13, gap: 12 }, + userName: { color: "#fff", fontWeight: 700 }, + roleTag: { + background: "#1f6f5b", + borderRadius: 999, + color: "#eaf3ef", + fontSize: 12, + padding: "2px 10px" + }, + loginLink: { color: "#fff", fontSize: 14, fontWeight: 700, textDecoration: "none" }, + logoutButton: { + background: "transparent", + border: "1px solid #4d7d6f", + borderRadius: 6, + color: "#eaf3ef", + cursor: "pointer", + fontSize: 13, + padding: "4px 12px" + } +} satisfies Record; + +// 角色中文名(外壳展示用,不参与权限判断;权限由后端 AuthGuard 强制)。 +const roleLabels: Record = { + admin: "管理员", + creator: "创作者", + operator: "运营", + player: "玩家" +}; + +export default async function RootLayout({ children }: { readonly children: ReactNode }) { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null; + // fail-safe:后端不可达或未登录时返回 null,外壳降级为「未登录」而非整页崩溃。 + const actor = await loadCurrentActor({ apiBaseUrl: apiBaseUrlFromEnv(), apiToken: token }); + return renderRootLayout(actor, children); +} + +// 纯渲染(不依赖请求上下文):便于单测外壳结构,与数据获取解耦。 +export function renderRootLayout(actor: CurrentActor | null, children: ReactNode) { + return createElement( + "html", + { lang: "zh-CN" }, + createElement("body", { style: { margin: 0 } }, renderHeader(actor), children) + ); +} + +function renderHeader(actor: CurrentActor | null) { + return createElement( + "header", + { style: styles.header }, + createElement( + "nav", + { style: styles.brandNav }, + createElement("a", { href: "/", style: styles.brand }, "汇景 AI"), + createElement("a", { href: "/workbench", style: styles.navLink }, "工作台") + ), + actor ? renderUserBox(actor) : createElement("a", { href: "/login", style: styles.loginLink }, "登录") + ); +} + +function renderUserBox(actor: CurrentActor) { + const role = primaryRole(actor); + return createElement( + "div", + { style: styles.userBox }, + createElement("span", { style: styles.userName }, actor.displayName), + role ? createElement("span", { style: styles.roleTag }, roleLabels[role] ?? role) : null, + createElement( + "form", + { action: "/api/auth/logout", method: "post" }, + createElement("button", { style: styles.logoutButton, type: "submit" }, "退出") + ) + ); } diff --git a/apps/web/src/app/login/page.test.ts b/apps/web/src/app/login/page.test.ts new file mode 100644 index 00000000..a116f5b6 --- /dev/null +++ b/apps/web/src/app/login/page.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { loginErrorMessage } from "./page"; + +describe("loginErrorMessage", () => { + it("maps known error codes to Chinese messages", () => { + expect(loginErrorMessage("invalid")).toContain("未知的本地测试用户"); + expect(loginErrorMessage("missing")).toContain("请先选择"); + }); + + it("takes the first value of a repeated query and ignores unknown codes", () => { + expect(loginErrorMessage(["invalid", "missing"])).toContain("未知的本地测试用户"); + expect(loginErrorMessage("whatever")).toBeNull(); + expect(loginErrorMessage(undefined)).toBeNull(); + }); +}); diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx new file mode 100644 index 00000000..fb7b490b --- /dev/null +++ b/apps/web/src/app/login/page.tsx @@ -0,0 +1,106 @@ +import type { CSSProperties } from "react"; +import { createElement } from "react"; + +// 登录页(服务端组件 + 原生表单):MVP 阶段为「确定性 demo 登录」——在后端 seeded 测试用户中按 email 选择,无密码。 +// 表单提交到 /api/auth/login,成功写 HttpOnly 会话 cookie 并跳工作台;失败带 ?error=invalid 回本页。 + +type LoginPageProps = { + readonly searchParams: Promise<{ readonly error?: string | readonly string[] }>; +}; + +// 与后端 apps/api/src/modules/auth seededUsers 对齐的本地测试用户(仅 demo,非生产账号)。 +const seededUsers = [ + { email: "creator@example.test", label: "创作者 creator(推荐:可创建项目)" }, + { email: "operator@example.test", label: "运营 operator(审核,S6 后开放)" }, + { email: "admin@example.test", label: "管理员 admin" }, + { email: "player@example.test", label: "玩家 player(试玩,S6 后开放)" } +] as const; + +const styles = { + main: { + color: "#18231f", + display: "grid", + fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + gap: 16, + margin: "0 auto", + maxWidth: 520, + padding: 32 + }, + title: { color: "#111c17", fontSize: 26, margin: 0 }, + subtitle: { color: "#54675e", fontSize: 14, lineHeight: 1.55, margin: 0 }, + demoTag: { + background: "#fff4d6", + border: "1px solid #f0d896", + borderRadius: 6, + color: "#7a5c12", + fontSize: 13, + margin: 0, + padding: "8px 12px" + }, + form: { display: "grid", gap: 12 }, + label: { color: "#28332e", fontSize: 14, fontWeight: 700 }, + select: { + border: "1px solid #c8d5ce", + borderRadius: 6, + fontSize: 15, + minHeight: 42, + padding: "0 10px" + }, + button: { + background: "#1f6f5b", + border: "none", + borderRadius: 6, + color: "#fff", + cursor: "pointer", + fontSize: 15, + fontWeight: 700, + minHeight: 44, + padding: "0 16px" + }, + error: { + background: "#fdecec", + border: "1px solid #f3c0c0", + borderRadius: 6, + color: "#9b2c2c", + fontSize: 14, + margin: 0, + padding: "8px 12px" + } +} satisfies Record; + +// 把 ?error 收敛为可理解的中文提示;未知/无错误返回 null。 +export function loginErrorMessage(error: string | readonly string[] | undefined): string | null { + const code = Array.isArray(error) ? error[0] : error; + if (code === "invalid") return "登录失败:未知的本地测试用户,请从下拉选择。"; + if (code === "missing") return "请先选择一个登录用户。"; + return null; +} + +export default async function LoginPage({ searchParams }: LoginPageProps) { + const { error } = await searchParams; + const errorMessage = loginErrorMessage(error); + + return createElement( + "main", + { style: styles.main }, + createElement("h1", { style: styles.title }, "登录 · 汇景 AI"), + createElement("p", { style: styles.subtitle }, "选择一个本地测试用户进入创作工作台。"), + createElement( + "p", + { style: styles.demoTag }, + "演示登录:当前为确定性 demo,使用后端预置的本地测试用户,无需密码,非生产账号体系。" + ), + errorMessage ? createElement("p", { role: "alert", style: styles.error }, errorMessage) : null, + createElement( + "form", + { action: "/api/auth/login", method: "post", style: styles.form }, + createElement("label", { htmlFor: "email", style: styles.label }, "登录用户"), + createElement( + "select", + { defaultValue: seededUsers[0].email, id: "email", name: "email", style: styles.select }, + ...seededUsers.map((user) => createElement("option", { key: user.email, value: user.email }, user.label)) + ), + createElement("button", { style: styles.button, type: "submit" }, "登录") + ) + ); +} diff --git a/apps/web/src/app/page.test.ts b/apps/web/src/app/page.test.ts new file mode 100644 index 00000000..4ed5b9e5 --- /dev/null +++ b/apps/web/src/app/page.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { homeEntriesForRoles } from "./page"; + +// 首页角色入口决策单测:creator/admin 可用创作入口;operator/player 为「即将开放」占位(href=null, available=false), +// 避免出现只能手搓 URL 才能到达的孤儿入口。 +describe("homeEntriesForRoles", () => { + it("gives creators an available workbench entry", () => { + const entries = homeEntriesForRoles(["creator"]); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ key: "creator", href: "/workbench", available: true }); + }); + + it("renders operator/player entries as coming-soon placeholders", () => { + const operator = homeEntriesForRoles(["operator"]); + expect(operator).toHaveLength(1); + expect(operator[0]).toMatchObject({ key: "operator", href: null, available: false }); + + const player = homeEntriesForRoles(["player"]); + expect(player[0]).toMatchObject({ key: "player", href: null, available: false }); + }); + + it("gives admins both creator and operator entries", () => { + const keys = homeEntriesForRoles(["admin"]).map((entry) => entry.key); + expect(keys).toEqual(["creator", "operator"]); + }); + + it("returns no entries for unknown roles", () => { + expect(homeEntriesForRoles(["ghost"])).toEqual([]); + }); +}); diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 8a46cf15..2e2bfefd 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,11 +1,120 @@ +import type { CSSProperties } from "react"; import { createElement } from "react"; -import { S1_PACKAGE_NAMES } from "@huijing/shared-contracts"; +import { cookies } from "next/headers"; +import { apiBaseUrlFromEnv, SESSION_COOKIE_NAME } from "./api/workbench/proxy"; +import { loadCurrentActor, type CurrentActor } from "./auth-data"; + +// 首页(服务端组件):按当前用户角色给出入口。未登录引导登录;creator 进创作工作台; +// operator/player 的入口在 S6 前标「即将开放」占位,避免出现只能手搓 URL 才能到达的孤儿入口。 + +type HomeEntry = { + readonly key: string; + readonly title: string; + readonly description: string; + readonly href: string | null; + readonly available: boolean; +}; + +const styles = { + main: { + color: "#18231f", + display: "grid", + fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + gap: 16, + margin: "0 auto", + maxWidth: 880, + padding: 32 + }, + title: { color: "#111c17", fontSize: 28, margin: 0 }, + subtitle: { color: "#54675e", fontSize: 15, lineHeight: 1.55, margin: 0 }, + list: { display: "grid", gap: 12, listStyle: "none", margin: 0, padding: 0 }, + card: { + border: "1px solid #d5ded8", + borderRadius: 10, + display: "grid", + gap: 6, + padding: 18 + }, + cardTitle: { color: "#15241d", fontSize: 17, fontWeight: 700, margin: 0 }, + cardDesc: { color: "#54675e", fontSize: 14, lineHeight: 1.5, margin: 0 }, + link: { color: "#1f6f5b", fontSize: 14, fontWeight: 700, textDecoration: "none" }, + soon: { color: "#9aa8a0", fontSize: 13, fontWeight: 700 }, + loginLink: { color: "#1f6f5b", fontSize: 16, fontWeight: 700, textDecoration: "none" } +} satisfies Record; + +// 根据角色集合计算首页入口卡片;creator 入口可用,operator/player 占位「即将开放」。 +export function homeEntriesForRoles(roles: readonly string[]): HomeEntry[] { + const entries: HomeEntry[] = []; + if (roles.includes("creator") || roles.includes("admin")) { + entries.push({ + available: true, + description: "创建项目、输入需求生成草稿、编辑分节、编译并预览试玩。", + href: "/workbench", + key: "creator", + title: "创作工作台" + }); + } + if (roles.includes("operator") || roles.includes("admin")) { + entries.push({ + available: false, + description: "审核创作者提交的版本、查看证据并决定发布。", + href: null, + key: "operator", + title: "运营审核台(即将开放)" + }); + } + if (roles.includes("player")) { + entries.push({ + available: false, + description: "浏览已发布的小游戏并试玩。", + href: null, + key: "player", + title: "游戏 Feed(即将开放)" + }); + } + return entries; +} + +export default async function HomePage() { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null; + const actor = await loadCurrentActor({ apiBaseUrl: apiBaseUrlFromEnv(), apiToken: token }); + return renderHomePage(actor); +} + +// 纯渲染(不依赖请求上下文):便于单测首页角色入口,与数据获取解耦。 +export function renderHomePage(actor: CurrentActor | null) { + if (!actor) { + return createElement( + "main", + { style: styles.main }, + createElement("h1", { style: styles.title }, "汇景 AI · 游戏生成平台"), + createElement("p", { style: styles.subtitle }, "用一句话需求,生成可在浏览器试玩的模拟经营小游戏。"), + createElement("a", { href: "/login", style: styles.loginLink }, "登录进入 →") + ); + } + + const entries = homeEntriesForRoles(actor.roles); -export default function HomePage() { return createElement( "main", - { "data-package": S1_PACKAGE_NAMES.web }, - createElement("h1", null, "Huijing AI App Foundation"), - createElement("p", null, `${S1_PACKAGE_NAMES.web} is ready.`) + { style: styles.main }, + createElement("h1", { style: styles.title }, `你好,${actor.displayName}`), + createElement("p", { style: styles.subtitle }, "选择一个入口开始。"), + createElement( + "ul", + { style: styles.list }, + ...entries.map((entry) => + createElement( + "li", + { key: entry.key, style: styles.card }, + createElement("p", { style: styles.cardTitle }, entry.title), + createElement("p", { style: styles.cardDesc }, entry.description), + entry.available && entry.href + ? createElement("a", { href: entry.href, style: styles.link }, "进入 →") + : createElement("span", { style: styles.soon }, "即将开放") + ) + ) + ) ); } diff --git a/apps/web/src/app/workbench/[projectId]/page.test.ts b/apps/web/src/app/workbench/[projectId]/page.test.ts new file mode 100644 index 00000000..7e50c688 --- /dev/null +++ b/apps/web/src/app/workbench/[projectId]/page.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { workbenchNotice } from "./page"; + +// 工作台状态横幅决策单测:PRG 重定向回来的 query 收敛为成功/失败横幅。 +// 编译成功措辞必须守住 S2 边界:只说「校验通过的 GameIR」,不得出现可玩/预览字样。 +describe("workbenchNotice", () => { + it("maps success markers to ok notices", () => { + expect(workbenchNotice({ generated: "ok" })).toEqual({ kind: "ok", message: "已根据需求生成草稿,可在下方查看与编辑分节。" }); + expect(workbenchNotice({ saved: "ok" })).toEqual({ kind: "ok", message: "分节已保存。" }); + expect(workbenchNotice({ compiled: "ok" })).toEqual({ kind: "ok", message: "已编译为校验通过的 GameIR。" }); + }); + + it("keeps the compile success copy within the S2 boundary (no playable/preview promise)", () => { + const notice = workbenchNotice({ compiled: "ok" }); + expect(notice?.message).not.toMatch(/可玩|预览|playable|preview/); + }); + + it("maps error codes to error notices and prioritizes errors over success markers", () => { + expect(workbenchNotice({ error: "generate" })).toEqual({ kind: "error", message: "生成失败,请稍后重试。" }); + expect(workbenchNotice({ error: "save" })).toMatchObject({ kind: "error" }); + expect(workbenchNotice({ error: "compile" })).toMatchObject({ kind: "error" }); + expect(workbenchNotice({ error: "version" })).toMatchObject({ kind: "error" }); + // 同时存在时错误优先。 + expect(workbenchNotice({ compiled: "ok", error: "compile" })?.kind).toBe("error"); + }); + + it("takes the first value of repeated query params and returns null when nothing matches", () => { + expect(workbenchNotice({ generated: ["ok", "x"] })?.kind).toBe("ok"); + expect(workbenchNotice({})).toBeNull(); + expect(workbenchNotice({ error: "unknown" })).toBeNull(); + }); +}); diff --git a/apps/web/src/app/workbench/[projectId]/page.tsx b/apps/web/src/app/workbench/[projectId]/page.tsx index 326beceb..8495bb58 100644 --- a/apps/web/src/app/workbench/[projectId]/page.tsx +++ b/apps/web/src/app/workbench/[projectId]/page.tsx @@ -1,6 +1,7 @@ import type { CSSProperties } from "react"; import { createElement } from "react"; import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; import { apiBaseUrlFromEnv } from "../../api/workbench/proxy"; import { MainCreationAgentChat } from "../../../components/workbench/MainCreationAgentChat"; import { loadWorkbenchProjectData } from "./page-data"; @@ -9,8 +10,16 @@ type WorkbenchProjectPageProps = { readonly params: Promise<{ readonly projectId: string; }>; + readonly searchParams: Promise<{ + readonly generated?: string | readonly string[]; + readonly saved?: string | readonly string[]; + readonly compiled?: string | readonly string[]; + readonly error?: string | readonly string[]; + }>; }; +type WorkbenchNotice = { readonly kind: "ok" | "error"; readonly message: string }; + const styles = { emptyMain: { color: "#18231f", @@ -32,11 +41,75 @@ const styles = { fontSize: 24, lineHeight: 1.2, margin: 0 + }, + button: { + background: "#1f6f5b", + border: "none", + borderRadius: 6, + color: "#fff", + cursor: "pointer", + fontSize: 14, + fontWeight: 700, + justifySelf: "start", + minHeight: 40, + padding: "0 16px" + }, + back: { color: "#1f6f5b", fontSize: 14, textDecoration: "none" }, + previewHint: { + color: "#7a8a82", + fontSize: 12, + margin: "0 auto", + maxWidth: 1280, + padding: "6px 16px" + }, + noticeOk: { + background: "#e8f6ee", + border: "1px solid #b6e0c6", + color: "#1f6f47", + fontSize: 14, + margin: "0 auto", + maxWidth: 1280, + padding: "10px 16px" + }, + noticeError: { + background: "#fdecec", + border: "1px solid #f3c0c0", + color: "#9b2c2c", + fontSize: 14, + margin: "0 auto", + maxWidth: 1280, + padding: "10px 16px" } } satisfies Record; -export default async function WorkbenchProjectPage({ params }: WorkbenchProjectPageProps) { +// 把 PRG 重定向回来的 ?generated/?saved/?compiled/?error 收敛为状态横幅。 +// 措辞守住 S2 边界:编译成功只说「校验通过的 GameIR」,不承诺可玩/预览。 +export function workbenchNotice(params: { + readonly generated?: string | readonly string[]; + readonly saved?: string | readonly string[]; + readonly compiled?: string | readonly string[]; + readonly error?: string | readonly string[]; +}): WorkbenchNotice | null { + const errorCode = firstValue(params.error); + if (errorCode === "generate") return { kind: "error", message: "生成失败,请稍后重试。" }; + if (errorCode === "save") return { kind: "error", message: "分节保存失败,请检查内容后重试。" }; + if (errorCode === "compile") return { kind: "error", message: "编译失败:请确认六个分节均已保存且有效后重试。" }; + if (errorCode === "version") return { kind: "error", message: "创建版本失败,请重试。" }; + if (firstValue(params.generated) === "ok") return { kind: "ok", message: "已根据需求生成草稿,可在下方查看与编辑分节。" }; + if (firstValue(params.saved) === "ok") return { kind: "ok", message: "分节已保存。" }; + if (firstValue(params.compiled) === "ok") return { kind: "ok", message: "已编译为校验通过的 GameIR。" }; + return null; +} + +function firstValue(value: string | readonly string[] | undefined): string | undefined { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value[0]; + return undefined; +} + +export default async function WorkbenchProjectPage({ params, searchParams }: WorkbenchProjectPageProps) { const { projectId } = await params; + const notice = workbenchNotice(await searchParams); const cookieStore = await cookies(); const token = cookieStore.get("huijing_api_token")?.value ?? null; const data = await loadWorkbenchProjectData({ @@ -46,25 +119,69 @@ export default async function WorkbenchProjectPage({ params }: WorkbenchProjectP }); if (data.kind === "empty") { - return createStatusPage("No draft version", data.message); + return createEmptyVersionPage(projectId, data.message, notice); } if (data.kind === "error") { - return createStatusPage("Workbench unavailable", data.message); + // 未登录 / 无权限:引导重新登录,而不是展示原始后端错误。 + if (data.status === 401 || data.status === 403) { + redirect("/login"); + } + return createStatusPage("工作台暂不可用", data.message, notice); } const chatProps = data.draft ? { draft: data.draft, projectId, versionId: data.version.id } : { projectId, useDemoDraft: false, versionId: data.version.id }; - return createElement(MainCreationAgentChat, chatProps); + // 在 chat 之上叠加状态横幅;不改 MainCreationAgentChat 组件本身(守住其既有 S2 边界测试)。 + // 页面层提示「可玩预览为后续能力」:编译止于校验通过的 GameIR;按版本试玩属 M2,详见审阅版。 + return createElement( + "div", + null, + renderNotice(notice), + createElement(MainCreationAgentChat, chatProps), + createElement( + "p", + { style: styles.previewHint }, + "提示:编译产出校验通过的 GameIR;按本版本的可玩预览为后续能力(M2)。可在项目列表查看平台可玩示例 demo。" + ) + ); } -function createStatusPage(title: string, message: string) { +function renderNotice(notice: WorkbenchNotice | null) { + if (!notice) return null; + return createElement( + "p", + { role: notice.kind === "error" ? "alert" : "status", style: notice.kind === "error" ? styles.noticeError : styles.noticeOk }, + notice.message + ); +} + +// 空状态:项目存在但还没有可编辑版本,提供「创建首个版本」原生表单兜底,避免无法进入的死路。 +function createEmptyVersionPage(projectId: string, message: string, notice: WorkbenchNotice | null) { return createElement( "main", { style: styles.emptyMain }, - createElement("h1", { style: styles.title }, title), - createElement("p", { style: styles.message }, message) + renderNotice(notice), + createElement("h1", { style: styles.title }, "尚无可编辑版本"), + createElement("p", { style: styles.message }, message), + createElement( + "form", + { action: `/api/workbench/projects/${encodeURIComponent(projectId)}/versions`, method: "post" }, + createElement("button", { style: styles.button, type: "submit" }, "创建首个版本") + ), + createElement("a", { href: "/workbench", style: styles.back }, "← 返回项目列表") + ); +} + +function createStatusPage(title: string, message: string, notice: WorkbenchNotice | null) { + return createElement( + "main", + { style: styles.emptyMain }, + renderNotice(notice), + createElement("h1", { style: styles.title }, title), + createElement("p", { style: styles.message }, message), + createElement("a", { href: "/workbench", style: styles.back }, "← 返回项目列表") ); } diff --git a/apps/web/src/app/workbench/new/page.test.ts b/apps/web/src/app/workbench/new/page.test.ts new file mode 100644 index 00000000..fad80366 --- /dev/null +++ b/apps/web/src/app/workbench/new/page.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { newProjectErrorMessage } from "./page"; + +describe("newProjectErrorMessage", () => { + it("maps the create error code to a Chinese hint and ignores others", () => { + expect(newProjectErrorMessage("create")).toContain("创建项目失败"); + expect(newProjectErrorMessage(["create"])).toContain("创建项目失败"); + expect(newProjectErrorMessage("nope")).toBeNull(); + expect(newProjectErrorMessage(undefined)).toBeNull(); + }); +}); diff --git a/apps/web/src/app/workbench/new/page.tsx b/apps/web/src/app/workbench/new/page.tsx new file mode 100644 index 00000000..adbcf061 --- /dev/null +++ b/apps/web/src/app/workbench/new/page.tsx @@ -0,0 +1,79 @@ +import type { CSSProperties } from "react"; +import { createElement } from "react"; + +// 创建项目页(服务端组件 + 原生表单):提交到 /api/workbench/projects, +// 由路由创建项目并自动建首个 draft version,成功后跳转项目工作台。 + +type NewProjectPageProps = { + readonly searchParams: Promise<{ readonly error?: string | readonly string[] }>; +}; + +const styles = { + main: { + color: "#18231f", + display: "grid", + fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + gap: 14, + margin: "0 auto", + maxWidth: 560, + padding: 32 + }, + title: { color: "#111c17", fontSize: 24, margin: 0 }, + subtitle: { color: "#54675e", fontSize: 14, lineHeight: 1.55, margin: 0 }, + form: { display: "grid", gap: 12 }, + label: { color: "#28332e", fontSize: 14, fontWeight: 700 }, + input: { border: "1px solid #c8d5ce", borderRadius: 6, fontSize: 15, minHeight: 42, padding: "0 10px" }, + hint: { color: "#7a8a82", fontSize: 12, margin: 0 }, + button: { + background: "#1f6f5b", + border: "none", + borderRadius: 6, + color: "#fff", + cursor: "pointer", + fontSize: 15, + fontWeight: 700, + minHeight: 44, + padding: "0 16px" + }, + back: { color: "#1f6f5b", fontSize: 14, textDecoration: "none" }, + error: { + background: "#fdecec", + border: "1px solid #f3c0c0", + borderRadius: 6, + color: "#9b2c2c", + fontSize: 14, + margin: 0, + padding: "8px 12px" + } +} satisfies Record; + +// 把 ?error 收敛为中文提示。 +export function newProjectErrorMessage(error: string | readonly string[] | undefined): string | null { + const code = Array.isArray(error) ? error[0] : error; + if (code === "create") return "创建项目失败:请确认已用 creator 角色登录,并填写有效标题后重试。"; + return null; +} + +export default async function NewProjectPage({ searchParams }: NewProjectPageProps) { + const { error } = await searchParams; + const errorMessage = newProjectErrorMessage(error); + + return createElement( + "main", + { style: styles.main }, + createElement("h1", { style: styles.title }, "创建项目"), + createElement("p", { style: styles.subtitle }, "填写项目标题,系统会自动为你建立第一个版本并进入创作工作台。"), + errorMessage ? createElement("p", { role: "alert", style: styles.error }, errorMessage) : null, + createElement( + "form", + { action: "/api/workbench/projects", method: "post", style: styles.form }, + createElement("label", { htmlFor: "title", style: styles.label }, "项目标题"), + createElement("input", { autoComplete: "off", id: "title", name: "title", placeholder: "例如:猫咖模拟经营", required: true, style: styles.input, type: "text" }), + createElement("label", { htmlFor: "slug", style: styles.label }, "项目标识 slug(可选)"), + createElement("input", { autoComplete: "off", id: "slug", name: "slug", placeholder: "留空则自动生成", style: styles.input, type: "text" }), + createElement("p", { style: styles.hint }, "slug 仅允许字母、数字与 . _ - ,留空将由系统生成。"), + createElement("button", { style: styles.button, type: "submit" }, "创建并进入工作台") + ), + createElement("a", { href: "/workbench", style: styles.back }, "← 返回项目列表") + ); +} diff --git a/apps/web/src/app/workbench/page.tsx b/apps/web/src/app/workbench/page.tsx index 65d69a31..f5286ee6 100644 --- a/apps/web/src/app/workbench/page.tsx +++ b/apps/web/src/app/workbench/page.tsx @@ -1,37 +1,13 @@ import type { CSSProperties } from "react"; import { createElement } from "react"; +import { cookies } from "next/headers"; +import { apiBaseUrlFromEnv, SESSION_COOKIE_NAME } from "../api/workbench/proxy"; +import { loadProjectsListData, type ProjectsListItem } from "./projects-data"; -const demoProjects = [ - { - id: "market-demo", - status: "Draft ready for section review", - title: "Neighborhood Market Draft", - versionId: "version-demo" - } -] as const; +// 创作工作台首页(服务端组件):展示当前登录用户拥有的真实项目(替换原 demoProjects 硬编码)。 +// 区分 未登录 / 空 / 就绪 / 错误,并提供「创建项目」入口。 const styles = { - item: { - border: "1px solid #d5ded8", - borderRadius: 8, - display: "grid", - gap: 8, - padding: 16 - }, - link: { - color: "#1f6f5b", - fontSize: 15, - fontWeight: 700, - textDecoration: "none" - }, - list: { - display: "grid", - gap: 12, - listStyle: "none", - margin: 0, - maxWidth: 720, - padding: 0 - }, main: { color: "#18231f", display: "grid", @@ -41,34 +17,97 @@ const styles = { maxWidth: 980, padding: 24 }, - meta: { - color: "#5a6d64", - fontSize: 13, - margin: 0 + headerRow: { alignItems: "center", display: "flex", justifyContent: "space-between" }, + title: { color: "#111c17", fontSize: 28, margin: 0 }, + createLink: { + background: "#1f6f5b", + borderRadius: 6, + color: "#fff", + fontSize: 14, + fontWeight: 700, + padding: "8px 14px", + textDecoration: "none" }, - title: { - color: "#111c17", - fontSize: 28, - margin: 0 - } + list: { display: "grid", gap: 12, listStyle: "none", margin: 0, maxWidth: 720, padding: 0 }, + item: { border: "1px solid #d5ded8", borderRadius: 8, display: "grid", gap: 8, padding: 16 }, + link: { color: "#1f6f5b", fontSize: 15, fontWeight: 700, textDecoration: "none" }, + meta: { color: "#5a6d64", fontSize: 13, margin: 0 }, + message: { color: "#52655c", fontSize: 14, lineHeight: 1.55, margin: 0 }, + loginLink: { color: "#1f6f5b", fontSize: 14, fontWeight: 700, textDecoration: "none" }, + sampleBox: { + background: "#f3f7f4", + border: "1px dashed #c8d5ce", + borderRadius: 8, + display: "grid", + gap: 6, + marginTop: 8, + maxWidth: 720, + padding: 14 + }, + sampleLink: { color: "#1f6f5b", fontSize: 14, fontWeight: 700, textDecoration: "none" }, + sampleNote: { color: "#7a8a82", fontSize: 12, margin: 0 } } satisfies Record; -export default function WorkbenchIndexPage() { +export default async function WorkbenchIndexPage() { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null; + const data = await loadProjectsListData({ apiBaseUrl: apiBaseUrlFromEnv(), apiToken: token }); + + if (data.kind === "unauthenticated") { + return createElement( + "main", + { style: styles.main }, + createElement("h1", { style: styles.title }, "创作工作台"), + createElement("p", { style: styles.message }, "请先登录后查看你的项目。"), + createElement("a", { href: "/login", style: styles.loginLink }, "前往登录 →") + ); + } + + if (data.kind === "error") { + return createElement( + "main", + { style: styles.main }, + createElement("h1", { style: styles.title }, "创作工作台"), + createElement("p", { style: styles.message }, data.message) + ); + } + return createElement( "main", { style: styles.main }, - createElement("h1", { style: styles.title }, "Creator workbench"), createElement( - "ul", - { style: styles.list }, - ...demoProjects.map((project) => - createElement( - "li", - { key: project.id, style: styles.item }, - createElement("a", { href: `/workbench/${project.id}`, style: styles.link }, project.title), - createElement("p", { style: styles.meta }, `${project.status} · ${project.versionId}`) + "div", + { style: styles.headerRow }, + createElement("h1", { style: styles.title }, "创作工作台"), + createElement("a", { href: "/workbench/new", style: styles.createLink }, "创建项目") + ), + data.kind === "empty" + ? createElement( + "p", + { style: styles.message }, + "你还没有项目。点击右上角「创建项目」开始你的第一个 AI 模拟经营小游戏。" ) - ) - ) + : createElement("ul", { style: styles.list }, ...data.projects.map(renderProjectItem)), + renderSampleDemo() + ); +} + +// 平台可玩示例 demo:链到已知可玩的 fixture 预览切片。 +// 明确标注「平台示例,非你的项目」,并说明按版本试玩为后续能力(M2)——避免误导,也给试玩一个诚实可达入口。 +function renderSampleDemo() { + return createElement( + "section", + { "aria-label": "平台可玩示例", style: styles.sampleBox }, + createElement("a", { href: "/preview/simulation-fixture-v1", style: styles.sampleLink }, "查看平台可玩示例 demo(浏览器试玩)→"), + createElement("p", { style: styles.sampleNote }, "平台示例,非你的项目。按你自己版本的可玩预览为后续能力(M2)。") + ); +} + +function renderProjectItem(project: ProjectsListItem) { + return createElement( + "li", + { key: project.id, style: styles.item }, + createElement("a", { href: `/workbench/${project.id}`, style: styles.link }, project.title), + createElement("p", { style: styles.meta }, `${project.status} · ${project.slug}`) ); } diff --git a/apps/web/src/app/workbench/projects-data.test.ts b/apps/web/src/app/workbench/projects-data.test.ts new file mode 100644 index 00000000..348bc05e --- /dev/null +++ b/apps/web/src/app/workbench/projects-data.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadProjectsListData, parseProjects } from "./projects-data"; + +const sampleProject = { + id: "proj-1", + ownerId: "seed-creator", + slug: "cat-cafe", + title: "猫咖模拟经营", + status: "active", + createdAt: "2026-06-06T00:00:00.000Z", + updatedAt: "2026-06-06T00:00:00.000Z" +}; + +describe("loadProjectsListData", () => { + it("returns unauthenticated without a token", async () => { + const fetchImpl = vi.fn(); + expect(await loadProjectsListData({ apiBaseUrl: "http://api.test", apiToken: null, fetchImpl: fetchImpl as unknown as typeof fetch })).toEqual({ + kind: "unauthenticated" + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("returns ready with parsed projects", async () => { + const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify([sampleProject]), { status: 200 }))); + const data = await loadProjectsListData({ apiBaseUrl: "http://api.test/", apiToken: "tok", fetchImpl }); + expect(data).toEqual({ kind: "ready", projects: [sampleProject] }); + expect(fetchImpl.mock.calls[0]![0]).toBe("http://api.test/projects"); + }); + + it("returns empty for an empty list", async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify([]), { status: 200 })); + expect(await loadProjectsListData({ apiBaseUrl: "http://api.test", apiToken: "tok", fetchImpl: fetchImpl as unknown as typeof fetch })).toEqual({ + kind: "empty" + }); + }); + + it("maps 401/403 to unauthenticated and other non-ok to error", async () => { + const forbidden = vi.fn(async () => new Response("no", { status: 403 })); + expect(await loadProjectsListData({ apiBaseUrl: "http://api.test", apiToken: "tok", fetchImpl: forbidden as unknown as typeof fetch })).toEqual({ + kind: "unauthenticated" + }); + + const serverError = vi.fn(async () => new Response("boom", { status: 500 })); + expect(await loadProjectsListData({ apiBaseUrl: "http://api.test", apiToken: "tok", fetchImpl: serverError as unknown as typeof fetch })).toEqual({ + kind: "error", + message: "项目列表加载失败(500)。", + status: 500 + }); + }); + + it("returns error on network failure", async () => { + const throwing = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }); + expect(await loadProjectsListData({ apiBaseUrl: "http://api.test", apiToken: "tok", fetchImpl: throwing as unknown as typeof fetch })).toEqual({ + kind: "error", + message: "无法连接项目服务,请稍后重试。", + status: 0 + }); + }); +}); + +describe("parseProjects", () => { + it("filters out malformed entries", () => { + expect(parseProjects([sampleProject, { id: "bad" }, null])).toEqual([sampleProject]); + expect(parseProjects("not-array")).toEqual([]); + }); +}); diff --git a/apps/web/src/app/workbench/projects-data.ts b/apps/web/src/app/workbench/projects-data.ts new file mode 100644 index 00000000..8303253d --- /dev/null +++ b/apps/web/src/app/workbench/projects-data.ts @@ -0,0 +1,77 @@ +// 项目列表加载器:供 /workbench 服务端渲染读取「当前登录用户拥有的真实项目」(替换原 demoProjects 硬编码)。 +// 返回判别联合,区分 未登录 / 空 / 就绪 / 错误,便于页面给出可理解的兜底。 + +type ProjectsFetch = typeof fetch; + +export type ProjectsListItem = { + readonly id: string; + readonly ownerId: string; + readonly slug: string; + readonly title: string; + readonly status: string; + readonly createdAt: string; + readonly updatedAt: string; +}; + +export type ProjectsListData = + | { readonly kind: "ready"; readonly projects: readonly ProjectsListItem[] } + | { readonly kind: "empty" } + | { readonly kind: "unauthenticated" } + | { readonly kind: "error"; readonly message: string; readonly status: number }; + +type LoadProjectsListDataInput = { + readonly apiBaseUrl: string; + readonly apiToken: string | null; + readonly fetchImpl?: ProjectsFetch; +}; + +export async function loadProjectsListData({ apiBaseUrl, apiToken, fetchImpl = fetch }: LoadProjectsListDataInput): Promise { + if (!apiToken) return { kind: "unauthenticated" }; + try { + const response = await fetchImpl(`${normalizeBaseUrl(apiBaseUrl)}/projects`, { + cache: "no-store", + headers: { + accept: "application/json", + authorization: `Bearer ${apiToken}` + }, + method: "GET" + }); + // 401/403 收敛为未登录,引导重新登录而非展示原始错误。 + if (response.status === 401 || response.status === 403) return { kind: "unauthenticated" }; + if (!response.ok) return { kind: "error", message: `项目列表加载失败(${response.status})。`, status: response.status }; + const projects = parseProjects(await response.json()); + return projects.length === 0 ? { kind: "empty" } : { kind: "ready", projects }; + } catch { + return { kind: "error", message: "无法连接项目服务,请稍后重试。", status: 0 }; + } +} + +export function parseProjects(value: unknown): ProjectsListItem[] { + if (!Array.isArray(value)) return []; + return value.filter(isProjectsListItem); +} + +function isProjectsListItem(value: unknown): value is ProjectsListItem { + return ( + isRecord(value) && + isNonEmptyString(value.id) && + isNonEmptyString(value.ownerId) && + isNonEmptyString(value.slug) && + isNonEmptyString(value.title) && + isNonEmptyString(value.status) && + isNonEmptyString(value.createdAt) && + isNonEmptyString(value.updatedAt) + ); +} + +function normalizeBaseUrl(value: string): string { + return value.replace(/\/+$/, ""); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} diff --git a/apps/web/src/components/workbench/MainCreationAgentChat.tsx b/apps/web/src/components/workbench/MainCreationAgentChat.tsx index c2e62406..db3a5e54 100644 --- a/apps/web/src/components/workbench/MainCreationAgentChat.tsx +++ b/apps/web/src/components/workbench/MainCreationAgentChat.tsx @@ -89,6 +89,15 @@ const styles = { gap: 14, padding: 18 }, + demoTag: { + background: "#fff4d6", + border: "1px solid #f0d896", + borderRadius: 6, + color: "#7a5c12", + fontSize: 13, + margin: 0, + padding: "8px 12px" + }, grid: { alignItems: "start", display: "grid", @@ -172,7 +181,13 @@ export function MainCreationAgentChat({ compileResult, draft, projectId, useDemo "header", { style: styles.header }, createElement("h1", { style: styles.title }, "Main Creation Agent"), - createElement("p", { style: styles.subtitle }, "Single conversation surface for draft generation, review, and section edits.") + createElement("p", { style: styles.subtitle }, "Single conversation surface for draft generation, review, and section edits."), + // M1 demo 标签:当前草稿由确定性模板生成器产出(非真实大模型),UI 明确告知,避免误解为真实 AI 生成。 + createElement( + "p", + { style: styles.demoTag }, + "模板生成 demo:当前草稿由确定性生成器产出(非真实大模型),用于演示创作流程;真实 LLM 接入为后续 P1 能力。" + ) ), createElement( "div", diff --git a/apps/web/src/smoke.test.ts b/apps/web/src/smoke.test.ts index 41dafee2..46107457 100644 --- a/apps/web/src/smoke.test.ts +++ b/apps/web/src/smoke.test.ts @@ -1,25 +1,27 @@ import type { ReactElement, ReactNode } from "react"; import { describe, expect, it } from "vitest"; -import RootLayout from "./app/layout"; -import HomePage from "./app/page"; +import { renderRootLayout } from "./app/layout"; +import { renderHomePage } from "./app/page"; describe("@huijing/web smoke", () => { it("returns an App Router root layout with html and body elements", () => { - const layout = RootLayout({ children: "content" }) as unknown as ReactElement<{ - children: ReactElement<{ children: ReactNode }>; + // 纯渲染(actor=null,未登录):验证外壳骨架仍是 html>body,并把页面 children 挂在 body 下。 + const layout = renderRootLayout(null, "content") as unknown as ReactElement<{ + children: readonly ReactElement<{ children: ReactNode }>[]; lang: string; }>; expect(layout.type).toBe("html"); - expect(layout.props.lang).toBe("en"); - expect(layout.props.children.type).toBe("body"); - expect(layout.props.children.props.children).toBe("content"); + expect(layout.props.lang).toBe("zh-CN"); + const body = layout.props.children as unknown as ReactElement<{ children: ReactNode }>; + expect(body.type).toBe("body"); + // body 子节点为 [header, children],页面内容仍在其中。 + const bodyChildren = body.props.children as readonly ReactNode[]; + expect(bodyChildren).toContain("content"); }); - it("renders the web package identity on the home page", () => { - const page = HomePage() as ReactElement<{ "data-package": string }>; - + it("renders the logged-out home page as a main element", () => { + const page = renderHomePage(null) as ReactElement<{ style: unknown }>; expect(page.type).toBe("main"); - expect(page.props["data-package"]).toBe("@huijing/web"); }); }); diff --git a/docs/agent-specs/2026-06-06-M1T5预览试玩-审阅版.md b/docs/agent-specs/2026-06-06-M1T5预览试玩-审阅版.md new file mode 100644 index 00000000..05e62ad7 --- /dev/null +++ b/docs/agent-specs/2026-06-06-M1T5预览试玩-审阅版.md @@ -0,0 +1,68 @@ +# M1-T5「编译→预览试玩」可行性与路径 — 审阅版 + +> 生成时间:2026-06-06 +> 受众:人类决策。 +> 关联:[[2026-06-06-MVP用户可试用M0M1-执行版]]、[[2026-06-05-WebPlayable里程碑]]。 +> 结论先行,证据均为代码级核验(含 file:line)。 + +## 1. 结论 + +**M1-T5「让创作者编译后试玩自己的版本」目前无法作为有意义的功能交付**,原因是存在两处独立缺口,且与一条受测试强制的阶段边界冲突。它不是"接线",而是依赖一个产品级运行时史诗。**建议:本期诚实标注并延后真实预览到 M2,按下方推荐路径排期。** + +## 2. 代码级核验事实(不可绕过) + +| # | 事实 | 证据 | +|---|------|------| +| F1 | 预览加载器只认 fixture,真实 versionId 直接报错 | `WebGameRuntime.tsx:451` `getPreviewPackageSource`:`versionId !== "simulation-fixture-v1"` → `{kind:"error"}` | +| F2 | 后端无「按版本取可运行 web 包」HTTP 端点 | `game-package` 模块仅有 `GamePackageBuilderService.buildWebPackage()`(`index.ts:203`),**无任何 @Controller / @Get / @Post** | +| F3 | 逻辑编译器产出的是**桩**,非可玩仿真 | `game-logic/index.ts:160-210` `renderLogicModuleSource`:`render()` 只返回一行 `templateType` 文本;`update` 仅累加 elapsedMs;`handleInput` 原样返回 state | +| F4 | 真正可玩的逻辑是**手写 fixture**,与编译器无关 | `WebGameRuntime.tsx:502` `loadSimulationPreviewLogicModule`(网格/NPC/金币/目标),硬编码,非由 GameConfig 生成 | +| F5 | 工作台被测试强制**不得**承诺 playable/preview | `MainCreationAgentChat.test.tsx`:`renderToStaticMarkup` 断言编译结果 HTML `not.toContain("playable"/"runtime preview"/...)`;S2 刻意止于 validated GameIR | + +**推论**:即使补上 F2 的交付端点,真实版本预览也只会显示 F3 的桩输出(一行文字),不是可玩游戏。真正的"试玩你的游戏"必须先有 **GameConfig → 真实仿真**(F3 的桩升级为真实运行时逻辑)。 + +## 3. 缺口分解 + +```mermaid +flowchart LR + A[GameConfig / GameIR
已有, 校验通过] --> B{缺口1: 真实逻辑} + B -- 现状: 桩(只画templateType) --> C[可运行逻辑模块] + C --> D{缺口2: 交付} + D -- 现状: 无HTTP端点, 加载器fixture-only --> E[Web 预览运行时
已有, 可跑fixture] + E --> F[创作者试玩自己的版本] + style B fill:#fde2e2,stroke:#c0392b + style D fill:#fde2e2,stroke:#c0392b +``` + +- **缺口1(真实逻辑,史诗级)**:把 `renderLogicModuleSource` 的桩升级为按 GameConfig(主题/地图/经济/NPC/设施/目标)驱动的真实仿真。这是产品运行时的核心,工作量大,且决定一切下游"可玩"。 +- **缺口2(交付,中等)**:新增后端端点把某版本的包(config + asset-manifest + logic + checksum)交付给 web 预览,并让 `getPreviewPackageSource` 改为按版本拉取。需 DB + 产物存储 + JS 模块/资源的安全交付(动态 import 或可移植解释器二选一)。 + +## 4. 候选路径(最小且正交) + +| 选项 | 内容 | 价值 | 成本/风险 | 沙箱可验 | +|------|------|------|----------|---------| +| **A(推荐)** | 本期诚实标注:工作台「编译产出校验通过的 GameIR;可玩预览为后续能力」;`/preview/simulation-fixture-v1` 作为**平台示例 demo**保留。不在工作台造跳转孤儿。 | 不误导、立即可交付、守住 S2 边界 | 极低 | ✅ 纯文案/链接 | +| **B** | 只补缺口2(交付端点 + 加载器按版本拉取),逻辑暂用 F3 桩。 | 打通管线,但预览仍只显示桩文字(非真实游戏) | 中:跨 web+api,需 DB/浏览器,端到端不可在沙箱验;**有误导风险**(用户以为是自己的游戏却是桩) | ⚠️ 仅单测端点/加载器 | +| **C(真功能)** | 先做缺口1(GameConfig→真实仿真解释器,web-runtime 可消费),再叠加缺口2交付。 | 真正实现"试玩你的游戏" | 高:产品运行时核心史诗,多组件,端到端需真实环境 | ❌ 可玩性需浏览器+真机 | + +## 5. 推荐 + +1. **本期(M1)采用 A**:把 M1-T5 的"可玩预览入口"明确标注为后续能力,工作台不承诺可玩;保留 fixture 作为投资人演示用的平台示例。**不改 S2 边界、不造孤儿、不伪造。** +2. **将 C 立为独立史诗(建议归入 M2「编译/预览闭环」)**:先设计 GameConfig→真实仿真解释器(缺口1),交付端点(缺口2)作为其收尾;届时再决定是否解除/迁移 S2 边界,让工作台或独立"试玩"页提供真实预览入口。 +3. **B 不单独推荐**:除非需要先打通交付管线做架构验证;若做,必须在 UI 显著标注"预览为占位/桩,非最终游戏",避免误导。 + +## 6. S2 边界决策(需确认) + +真实预览最终需要在某处提供"试玩你的版本"入口。两种落点: +- **不动 `MainCreationAgentChat`**:在工作台**页面层**(`workbench/[projectId]/page.tsx`)或独立 `/preview` 引导,组件级 S2 边界测试不受影响(推荐)。 +- 或显式修改 S2 边界测试与组件——需要团队同意阶段边界变更。 + +## 7. 待确认 + +1. 选择路径 A / B / C(决定本期是否动预览管线)。 +2. 若最终做真实预览:缺口1 解释器走"通用解释器(前端可移植,无需动态 import JS)"还是"编译 .mjs + 动态 import"?(影响安全模型与架构) +3. S2 边界落点:页面层引导(不改组件)还是修改组件边界? + +--- + +*本审阅版基于 git `7c7879c` 代码级核验。M1-T5 真实预览在结论确认前不实现,避免产出桩/孤儿或破坏受测边界。* diff --git a/docs/agent-specs/2026-06-06-MVP用户可试用M0M1-执行版.md b/docs/agent-specs/2026-06-06-MVP用户可试用M0M1-执行版.md new file mode 100644 index 00000000..8fcb20ce --- /dev/null +++ b/docs/agent-specs/2026-06-06-MVP用户可试用M0M1-执行版.md @@ -0,0 +1,263 @@ +# MVP 用户可试用待办(M0+M1)— 执行版 + +> 生成时间:2026-06-06 +> 范围决策:用户已确认本执行版**只覆盖 M0+M1**(产品外壳 + 创作主流程闭环)。M2(小游戏导出/证据)、M3(发布审核/feed/遥测/S8 Go-No-Go)另起执行版。 +> AI 定位决策:用户已确认**保留确定性生成器 + UI 明确 demo 标签**,真实 LLM 适配器作为有文档的 P1 接缝,本版不接真实模型。 +> 受众:实现 agent。 +> 基线 git HEAD:`7c7879c`(S0–S5 已合并 + Web/WebGL playable slice)。 +> 关联:[[2026-06-05-MVP剩余清单与路线]]、[[2026-06-05-WebPlayable里程碑]]。 + +--- + +## 0. 结论(先行) + +- **M0 是真实缺口**:登录页、应用外壳/导航、当前用户与退出、auth 代理、projects 代理 + 真实项目列表 + 创建项目,**Web 端均未实现**(后端 API 已就绪)。 +- **M1 大部分已就位**:`workbench/[projectId]/page.tsx` 已有 cookie 读取 + 空态/错误态/草稿态三分支 + `MainCreationAgentChat`;创作组件(PromptComposer / GenerationStatusPanel / SectionNav + 6 个分节编辑器)与代理 builder(prompt / 保存分节 / 编译 / 列版本 / 读草稿)均已存在。M1 工作主体是**接线端到端核验 + demo 标签表面化 + 401/403 兜底**,而非从零开发。 +- **现有 `/`、`/workbench` 是占位桩/demo**,本版将其替换为真实外壳与真实数据,须保证 `/preview/[versionId]` 不回归。 +- **验证天花板(硬约束,引自剩余清单 §6)**:本沙箱只能真验证 `lint`/`typecheck`/`unit`/`scope-gate`/`build:minigame`。**真实项目列表/创建/草稿落库依赖 Postgres,本沙箱无法端到端真验证**;这部分验收必须在具备 DB 的真实环境完成,本版按"沙箱可验 / 需 DB 环境"分层标注,不在沙箱伪造为通过。 + +--- + +## 0.1 实现状态(2026-06-06,已落地) + +> 详细进展与架构发现见 [[2026-06-06-MVP可试用M0M1执行版]](记忆);M1-T5 决策见 `2026-06-06-M1T5预览试玩-审阅版.md`。 + +| 任务 | 状态 | 说明 | +|------|------|------| +| M0-T1~T6 | ✅ 已实现(沙箱验证) | 登录/外壳/真实项目列表/创建项目+自动建首版/角色入口;原生表单 + 303 PRG + HttpOnly cookie | +| M1-T1 | ✅ | 工作台 demo 标签 | +| M1-T2/T3/T4 | ✅ 接线(端到端待 DB) | 生成/保存/编译三路由改 PRG 重定向回工作台 + 详情页状态横幅 | +| M1-T6 | ✅ | 401/403→登录、空态建版兜底、失败横幅 | +| M1-T5 | ⏸️ 路径 A(标注+延后 M2) | 经核验为「桩逻辑 + 无交付端点 + S2 边界冲突」,不可作有意义功能交付;工作台诚实标注「可玩预览为后续能力」,保留 fixture 作平台示例,不造孤儿。真实每版本预览立为 M2 史诗 | + +**沙箱验证**:`@huijing/web` typecheck / lint / 88 tests / `next build` / `git diff --check` 全绿。 +**沙箱天花板**:项目/版本/草稿落库与生成/保存/编译端到端真跑通需真实 Postgres,未在沙箱宣称通过。 + +--- + +## 1. 目标与范围边界 + +### 目标 + +让一个 creator 用户**无需手搓 cookie / URL** 即可:登录 → 看到自己的真实项目 → 创建项目(自动进首个版本)→ 输入自然语言需求 → 生成草稿 → 查看/编辑/保存分节 → 一键编译 → 跳转预览试玩。 + +### 在范围内(M0+M1) + +- M0:`/login` 登录页;应用外壳(导航 + 当前用户 + 角色 + 退出);auth 代理(login/logout/me);projects 代理(list/create/get);真实项目列表(替换 demoProjects);创建项目 UI + 自动建首版;基于角色的入口可见性。 +- M1:demo 标签表面化;prompt→生成→状态(generating/success/failure/retry)端到端核验;分节查看/编辑/保存核验;一键编译 + 结果/错误展示;编译成功→预览入口;401/403/无数据/生成失败/编译失败的可理解兜底。 + +### 明确不在范围内(本版不做) + +- M2:小游戏导出 UI、构建包下载、static gate 产品化、DevTools `result="passed"` 证据(仍是硬 No-Go,不碰)。 +- M3:发布申请、运营审核台、feed、分享页、互动、`/events/batch` 真实摄取、反馈看板、S8 env 校验/seed/smoke/healthcheck/Go-No-Go。 +- 真实 LLM 适配器接入(仅在文档中预留接缝,见 §5.4)。 +- 替换内存态 seeded 用户鉴权为真实账号体系(M0 沿用 seeded 用户,见 §3)。 + +--- + +## 2. 前置条件 + +| 前置 | 状态 | 说明 | +|---|---|---| +| 后端 auth API | ✅ 已就绪 | `POST auth/login`、`POST auth/logout`、`GET me`(`apps/api/src/modules/auth/index.ts:160-173`) | +| 后端 projects API | ✅ 已就绪 | `POST/GET projects`、`GET projects/:id`、`POST/GET projects/:id/versions`(`apps/api/src/modules/projects/index.ts:248-275`),AuthGuard + RBAC 服务端强制 | +| 后端 creation-agent API | ✅ 已就绪 | `POST .../creation-agent/messages`、`GET .../session`、`GET .../draft`(`apps/api/src/modules/creation-agent/index.ts:1003-1027`) | +| 后端编译 API | ✅ 已就绪 | `.../compile-game-ir`(web 代理 `buildCompileGameIRProxyRequest` 已存在) | +| 确定性生成器 | ✅ 已就绪 | `DeterministicAIGameDesignGenerator`,templateVersion `simulation-management-deterministic-v1`(`apps/api/src/modules/ai-design/index.ts:209`) | +| Web 代理基座 | ✅ 已就绪 | `apps/web/src/app/api/workbench/proxy.ts`:`proxyBuiltRequest` / `apiBaseUrlFromEnv` / cookie `huijing_api_token` 提取 / Bearer 转发 | +| 真实 Postgres(M0/M1 端到端真验证用) | ⚠️ 本沙箱无 | DB 集成验证须在真实环境完成 | + +--- + +## 3. 鉴权机制现状(实现 agent 必读,勿臆造) + +- 后端鉴权是**内存态 seeded 用户 + 随机 Bearer token**,**非密码、非持久 session**。`login({email}|{userId})` 在 4 个 seeded 用户(`admin@example.test` / `operator@example.test` / `creator@example.test` / `player@example.test`)中匹配,返回 `{ token, actor }`;session 存内存 Map,**API 重启即失效**。 +- Web 侧约定:登录成功后把 `token` 写入 cookie **`huijing_api_token`**(proxy 已按此名读取,`proxy.ts:156`)。受保护 API 由后端 `AuthGuard` 在服务端强制拒绝匿名(`auth/index.ts:140-153`)——**权限边界在后端,前端只做体验,不作为安全边界**。 +- M0 登录页因此是**"选择/输入 seeded 邮箱即登录"的 demo 级登录**,UI 须明示这是本地测试用户,不得伪装成生产账号体系。 +- Cookie 安全:写 `huijing_api_token` 时设 `HttpOnly`(proxy 在服务端读取,无需 JS 访问)、`SameSite=Lax`、`Path=/`;生产环境加 `Secure`。 + +--- + +## 4. 涉及模块与文件路径 + +### M0 新增 / 修改 + +| 动作 | 路径 | 说明 | +|---|---|---| +| 新增 | `apps/web/src/app/login/page.tsx` | 登录页:seeded 邮箱输入/选择 → 调 `/api/auth/login` → 写 cookie → 跳转 | +| 新增 | `apps/web/src/app/api/auth/login/route.ts` | 代理 `POST auth/login`,成功时 `Set-Cookie: huijing_api_token` | +| 新增 | `apps/web/src/app/api/auth/logout/route.ts` | 代理 `POST auth/logout`,清除 cookie | +| 新增 | `apps/web/src/app/api/auth/me/route.ts` | 代理 `GET me`,供外壳读取当前用户 | +| 修改 | `apps/web/src/app/api/workbench/proxy.ts` | 新增 `buildLogin/Logout/Me` 与 `buildListProjects/CreateProject/GetProject` proxy builder(复用既有 `proxyBuiltRequest`/cookie 提取) | +| 新增 | `apps/web/src/app/api/workbench/projects/route.ts` | `GET`(列项目)/`POST`(建项目)代理 | +| 修改 | `apps/web/src/app/layout.tsx` | 应用外壳:导航 + 当前用户/角色 + 退出入口;未登录态处理 | +| 重写 | `apps/web/src/app/page.tsx` | 首页 → 角色感知入口(creator 进工作台;operator/player 入口占位"即将开放",避免孤儿入口) | +| 重写 | `apps/web/src/app/workbench/page.tsx` | 删除 `demoProjects` 硬编码,改为读真实 `GET projects` + 空态 + 创建项目入口 | +| 新增 | `apps/web/src/app/workbench/new/page.tsx`(或列表页内联表单) | 创建项目表单 → `POST projects` → `POST projects/:id/versions` → 跳 `/workbench/:id` | + +### M1 修改(主要是接线核验 + 标签 + 兜底) + +| 动作 | 路径 | 说明 | +|---|---|---| +| 核验/修改 | `apps/web/src/components/workbench/MainCreationAgentChat.tsx` | demo 标签;prompt→生成→状态流;分节编辑保存;编译触发;预览跳转入口 | +| 核验 | `apps/web/src/components/workbench/PromptComposer.tsx` / `GenerationStatusPanel.tsx` / `SectionNav.tsx` + 6 个分节组件 | 确认真实接 creation-agent 接口而非本地假数据 | +| 修改 | `apps/web/src/app/workbench/[projectId]/page.tsx` / `page-data.ts` | 401 未登录 → 跳 `/login`;403 → 无权限提示;保留既有 empty/error 三态 | +| 核验 | `apps/web/src/app/preview/[versionId]/page.tsx` | 编译成功后入口跳转目标,确认不回归(slice 已交付) | + +> 实现 agent 务必先读上述组件**确认现有接线**,再决定"核验通过"还是"需补线",不要默认重写。 + +--- + +## 5. 数据流与接口契约 + +### 5.1 登录流 + +``` +/login 表单(email) + → POST /api/auth/login (web 代理) + → POST {API}/auth/login body:{email} + ← { token, actor } + ← Set-Cookie: huijing_api_token=; HttpOnly; SameSite=Lax; Path=/ + → 重定向 / 或 /workbench +``` + +### 5.2 项目列表 / 创建 / 自动建版 + +``` +/workbench (server 组件) + → GET /api/workbench/projects → GET {API}/projects (Bearer from cookie) → ProjectDto[] + 空数组 → 渲染空态 + "创建项目" + +创建项目: + POST /api/workbench/projects {title, slug?} → POST {API}/projects → ProjectDto + → POST /api/workbench/projects/:id/versions {configJson:{}} → VersionDto + → redirect /workbench/:projectId +``` + +- `ProjectDto`:`{ id, ownerId, slug, title, status, createdAt, updatedAt }`(`projects/index.ts:36-44`)。 +- `VersionDto`:`{ id, projectId, versionNumber, status, configJson, createdAt, updatedAt }`(同上 46-54)。 +- RBAC:仅 creator 可建项目(后端 `createProject` 强制,`index.ts:109-112`);列表对 creator 仅返回自己拥有的项目(`listProjects` where ownerId,`index.ts:148-155`)。前端入口可见性须与此一致,避免诱导 403。 + +### 5.3 创作主流程(M1,复用既有接口) + +``` +prompt 输入 → POST .../creation-agent/messages (既有 buildCreationAgentPromptProxyRequest) + → 确定性生成器产出 draft(templateVersion=simulation-management-deterministic-v1) +GET .../creation-agent/draft → 渲染分节 +编辑分节 → 保存(既有 buildSaveSectionProxyRequest, config_edit) +一键编译 → .../compile-game-ir (既有 buildCompileGameIRProxyRequest) +编译成功 → 跳 /preview/:versionId +``` + +### 5.4 真实 LLM 接缝(P1,本版仅文档化,不实现) + +确定性生成器实现 `AIGameDesignGenerator` 接口(`ai-design/index.ts`)。真实 LLM 适配器应实现同一接口并在 DI 处替换;接入时必须补:超时、重试、限流、错误码、审计日志(请求 id / project-version / 耗时 / 失败原因,且不泄露敏感内容)。本版**不接**,仅在 `docs/future/` 记一条接缝说明(可选)。UI 须明确标注当前为"模板生成 demo"。 + +--- + +## 6. 边界与失败路径(必须产品化,不得 500) + +| 场景 | 期望行为 | +|---|---| +| 未登录访问受保护页/接口(401/403/FORBIDDEN) | Web 重定向 `/login`,不暴露后端错误体 | +| 非 creator 访问创建项目 | 入口不可见;若强行调用,展示"需要 creator 角色"可理解提示(后端已 403) | +| 项目列表为空 | 空态文案 + "创建项目"主行动,不空白、不报错 | +| 无 draft 版本 | 工作台空态引导输入 prompt 生成(详情页 `empty` 分支已存在,复用) | +| 生成失败 / 超时 | GenerationStatusPanel 显示 failure + 重试入口 | +| 编译失败 | 展示失败规则/文件/建议动作(M1 至少展示后端返回的错误信息,不静默) | +| API 重启致 token 失效 | me/受保护接口 401 → 跳 `/login` 重新登录(seeded 内存 session 特性,UI 文案可提示) | +| cookie 缺失 | proxy 已返回 401(`proxy.ts` 既有行为),前端据此跳登录 | + +--- + +## 7. 验证方法(按沙箱可验 / 需 DB 环境分层) + +### 7.1 本沙箱可产出真实证据 + +| 验证 | 命令 | 覆盖 | +|---|---|---| +| 代理 builder 单测 | `pnpm --filter @huijing/web test`(扩展 `proxy.test.ts`) | login/logout/me/projects builder 的 header/body/cookie/401 行为 | +| 登录页 / 列表页 / 创建表单逻辑单测 | `pnpm --filter @huijing/web test` | cookie 写入决策、空态渲染、角色入口可见性、401→跳转决策(纯函数化后单测) | +| 组件接线单测 | `pnpm --filter @huijing/web test` | MainCreationAgentChat / 状态面板 / 分节保存 既有用例不回归 + demo 标签存在断言 | +| 类型检查 | `pnpm --filter @huijing/web typecheck` | 全量类型 | +| Lint | `pnpm --filter @huijing/web lint` | 仅按既定按包跑(仓库级 lint 会 OOM) | +| dev server 路由可达 | `next dev` + `GET /login` `/workbench` `/preview/...` HTTP 200 | SSR 输出含预期锚点 | +| 空白/冲突 | `git diff --check` | clean | + +### 7.2 必须在真实 DB 环境验证(本沙箱不可伪造为通过) + +- 登录 → `GET projects` 返回当前用户真实项目(需 DB + seeded 用户落库)。 +- 创建项目 → 自动建首版 → 跳工作台(`POST projects` / `POST versions` 真实写库,AuditService 落审计)。 +- prompt → 生成 → 草稿落库 → 分节保存 → 编译 → 预览 端到端。 +- RBAC:creator 看不到他人项目;非 creator 建项目被拒。 + +> 上述需 DB 项目在沙箱内只做"代理/前端逻辑单测 + dev server 路由可达"层面的证据;端到端真跑通的结论必须由真实 DB 环境产出,不得在本版宣称已通过。 + +--- + +## 8. 完成条件(Definition of Done) + +**M0 完成**(沙箱层 + 需 DB 环境层均满足): + +1. `/login` 可登录并写 `huijing_api_token` cookie;登录失败有明确提示。 +2. 外壳显示当前用户/角色 + 退出入口;退出清 cookie 并回登录。 +3. `/workbench` 展示真实项目(非 demoProjects),空态可引导创建。 +4. 创建项目成功并自动进入首个版本工作台(DB 环境验证)。 +5. 角色入口正确:creator 见创作入口;operator/player 见"即将开放"占位,无手搓 URL 才能到达的孤儿入口。 +6. 7.1 全部命令绿;7.2 项目在 DB 环境跑通并留证据。 + +**M1 完成**: + +1. 工作台明确标注"模板生成 demo(确定性生成器)"。 +2. prompt→生成→状态(generating/success/failure/retry)端到端可见。 +3. 分节可查看/编辑/保存。 +4. 一键编译展示结果与错误。 +5. 编译成功可跳预览试玩,`/preview/[versionId]` 不回归。 +6. §6 所有失败路径均有可理解提示,无未捕获 500。 + +--- + +## 9. 影响面、兼容性与回滚 + +- **影响面**:替换 `/`、`/workbench` 占位/demo 页;layout 增加外壳;proxy 增加 builder(纯增量,不改既有 builder 签名);新增 auth/projects 代理路由。 +- **兼容性**:后端 API 零改动(只新增 Web 调用方);既有 creation-agent / compile / preview 链路不动其契约;`proxy.test.ts` 既有用例须保持通过。 +- **风险**:①seeded 内存 session 重启失效——属已知 demo 限制,UI 文案提示即可,不在本版改鉴权体系;②创建项目→建版为两步非原子,第二步失败需提示并允许重试(不留孤儿项目阻断流程)。 +- **回滚**:纯前端 + 代理新增,回滚 = 还原本版改动的 Web 文件;后端无迁移、无数据变更,回滚无数据风险。 + +--- + +## 10. 任务分解与并行分组 + +| 任务 | 标题 | 依赖 | 验证 | +|---|---|---|---| +| M0-T1 | auth 代理(login/logout/me)+ cookie 写入 | proxy 基座 | unit(沙箱) | +| M0-T2 | `/login` 登录页 | M0-T1 | unit + dev server 200 | +| M0-T3 | 应用外壳(导航/当前用户/退出/角色入口) | M0-T1 | unit + dev server 200 | +| M0-T4 | projects 代理(list/create/get)+ 路由 | proxy 基座 | unit;端到端需 DB | +| M0-T5 | 真实项目列表页 + 空态(替换 demoProjects) | M0-T4 | unit;列表数据需 DB | +| M0-T6 | 创建项目 UI + 自动建首版 + 跳转 | M0-T4 | unit;写库需 DB | +| M1-T1 | demo 标签表面化 | — | unit | +| M1-T2 | prompt→生成→状态 接线核验 | M0 完成 | unit;端到端需 DB | +| M1-T3 | 分节查看/编辑/保存 接线核验 | M1-T2 | unit;写库需 DB | +| M1-T4 | 一键编译 + 结果/错误展示 | M1-T3 | unit;端到端需 DB | +| M1-T5 | 编译成功→预览入口 | M1-T4 | dev server 200 | +| M1-T6 | 401/403/失败路径兜底 | M0-T1 | unit | + +**并行分组**: +- Group A(沙箱可并行起步):{M0-T1, M0-T4}(代理 builder,纯增量、可单测)。 +- Group B(依赖 A):{M0-T2, M0-T3} 依赖 T1;{M0-T5, M0-T6} 依赖 T4。 +- Group C(M0 后):M1-T1 可立即;M1-T2→T3→T4→T5 串行;M1-T6 依赖 M0-T1。 + +**强制串行点**:创建项目(T6)依赖 projects 代理(T4);预览入口(M1-T5)依赖编译(M1-T4)。 + +--- + +## 11. 待确认项(不阻塞起步,建议实现前定) + +1. 登录页交互形态:seeded 邮箱**下拉选择**(更像 demo、防输错)还是**自由输入**?建议下拉,明示"本地测试用户"。 +2. operator/player 入口在 M0:占位"即将开放" vs 完全隐藏?建议占位,传达产品全貌但不造孤儿入口。 +3. 创建项目自动建版失败时:保留已建项目并允许重试建版 vs 提示并停留?建议保留 + 重试(避免误导用户重复建项目)。 + +--- + +*本执行版基于 git HEAD `7c7879c` 与代码级核验编写。M2/M3 执行版待 M0+M1 验收后另立。任何"端到端通过"声称必须区分沙箱单测证据与真实 DB 环境证据,不得混淆。* diff --git a/docs/memorys/2026-06-05-MVP剩余清单与路线.md b/docs/memorys/2026-06-05-MVP剩余清单与路线.md index 1306ca61..3c723166 100644 --- a/docs/memorys/2026-06-05-MVP剩余清单与路线.md +++ b/docs/memorys/2026-06-05-MVP剩余清单与路线.md @@ -19,7 +19,7 @@ Web playable demo slice,路由 `/preview/simulation-fixture-v1`,Canvas2D 默 |------|------|---------| | S0 harness | 完成 | git `6e363ae` 起多个 feat/harness 提交,门禁 PASS | | S1 app foundation | 完成 | git `bba5e13`,Task9 scope gate PASS | -| S2 creator workbench | 完成 | git `8ffeba9`-`b4c17c8` 系列提交 | +| S2 creator workbench | 后端层完成;产品外壳层未实现 | git `8ffeba9`-`b4c17c8`;**注**:合同/API/IR 编译器/scope gate 完成,但登录 UI/真实项目列表/创建项目/外壳导航从未实现,详见 [[2026-06-06-MVP可试用M0M1执行版]] | | S3 simulation slice | 完成 | git `e81e433`-`76d2444`,scope gate PASS | | S4 web runtime | 完成 | runtime smoke PASS(23 files / 244 tests),见 `2026-06-05-S4Task5运行冒烟.md` | | S5 mini-game conversion | static_validated 仅;import cert = No-Go | 三端 static gate PASS,DevTool import smoke 默认写 blocker,无 passed evidence | diff --git a/docs/memorys/2026-06-06-MVP可试用M0M1执行版.md b/docs/memorys/2026-06-06-MVP可试用M0M1执行版.md new file mode 100644 index 00000000..65628d64 --- /dev/null +++ b/docs/memorys/2026-06-06-MVP可试用M0M1执行版.md @@ -0,0 +1,70 @@ +# MVP 可试用 M0+M1 执行版(摘要) + +> 生成时间:2026-06-06 +> 基线 git HEAD:`7c7879c` +> 全文:`docs/agent-specs/2026-06-06-MVP用户可试用M0M1-执行版.md` +> 关联:[[2026-06-05-MVP剩余清单与路线]]、[[2026-06-05-WebPlayable里程碑]] + +## 背景与决策 + +围绕"用户可试用 MVP"补"产品外壳/用户入口"这条与 S6–S8 正交的线。用户已确认两项决策: + +- **范围 = 只做 M0+M1**(产品外壳 + 创作主流程闭环);M2(导出/证据)、M3(发布审核/feed/遥测/S8)另起执行版。 +- **AI 定位 = 保留确定性生成器 + UI 明确 demo 标签**;真实 LLM 适配器仅留有文档接缝,本版不接模型。 + +## 代码级核验的关键结论(勿再臆造) + +1. **"S2 完成"指后端层,非产品外壳层**:合同/API/IR 编译器/scope gate 已完成;但面向用户的**登录 UI、真实项目列表、创建项目、外壳导航**从未实现。`/`、`/workbench` 当前是占位桩 / 硬编码 `demoProjects`。 +2. **M1 完成度高**:`apps/web/src/app/workbench/[projectId]/page.tsx` 已有 cookie 读取 + 空态/错误态/草稿态三分支;创作组件(PromptComposer / GenerationStatusPanel / SectionNav + 6 分节编辑器 / MainCreationAgentChat)与代理 builder(prompt/保存分节/编译/列版本/读草稿)均已存在;`DeterministicAIGameDesignGenerator`(templateVersion `simulation-management-deterministic-v1`)已落地。→ M1 主体是**接线核验 + demo 标签 + 401/403 兜底**,非从零开发。 +3. **真实缺口集中在 M0**:`/login`、auth 代理(login/logout/me)、projects 代理 + 真实列表、创建项目、外壳(导航/当前用户/角色入口/退出)。 +4. **鉴权机制**:内存态 seeded 用户(admin/operator/creator/player @example.test)+ 随机 Bearer token,非密码/非持久,API 重启即失效。Web 侧 cookie 名 **`huijing_api_token`**,proxy(`apps/web/src/app/api/workbench/proxy.ts`)已支持 cookie/Bearer 提取与转发。权限边界由后端 `AuthGuard` 强制,前端不作安全边界。 +5. **验证天花板**:代理/前端逻辑/typecheck/lint/dev-server 路由本沙箱可真验证;**项目列表/创建/草稿落库依赖 Postgres,端到端必须真实 DB 环境验证**,不得在沙箱伪造为通过。 + +## 任务骨架 + +M0:M0-T1 auth 代理+cookie → T2 登录页 / T3 外壳;M0-T4 projects 代理 → T5 真实列表 / T6 创建项目+自动建首版。 +M1:T1 demo 标签 → T2 生成状态 → T3 分节编辑保存 → T4 编译 → T5 预览入口;T6 失败兜底。 +并行起步点:{M0-T1, M0-T4}(纯增量代理 builder,可单测)。 + +## 待确认项(非阻塞) + +登录页形态(下拉 seeded 邮箱 vs 自由输入)、operator/player 入口(占位"即将开放" vs 隐藏)、建版失败处理(保留项目+重试 vs 停留)。 + +## 实现进展(2026-06-06,git 待提交) + +已实现并通过沙箱验证(`@huijing/web`:typecheck / lint / 88 测试 / `next build` 全绿): + +- **M0 全量**: + - 鉴权采用「原生表单 + 303 PRG 重定向 + HttpOnly cookie」,**非客户端 fetch**(对齐全仓 createElement + 原生表单范式)。token 只进 `huijing_api_token` HttpOnly cookie,登录响应只回 actor。 + - `proxy.ts` 新增:`buildLogin/Logout/Me`、`buildCreateProject/CreateDraftVersion`、`extractLoginResult/extractCreatedProjectId`、cookie 序列化、redirect 执行器(login/logout/createProject/createDraftVersion/通用 formPostAndRedirect)。 + - 路由:`api/auth/{login,logout,me}`、`api/workbench/projects`(POST)、`versions`(新增 POST)。 + - 页面:`/login`(seeded 用户下拉,明示 demo)、外壳 `layout`(导航 + 当前用户/角色 + 退出)、首页 `/`(角色入口,operator/player 占位「即将开放」)、`/workbench`(真实项目列表 `loadProjectsListData`,替换 demoProjects)、`/workbench/new`(建项目→自动建首版→跳转)。 + - 数据加载器:`auth-data.ts`(`loadCurrentActor` fail-safe)、`workbench/projects-data.ts`(`loadProjectsListData`)。 +- **M1-T1/T2/T3/T4/T6**: + - demo 标签:`MainCreationAgentChat` 头部加「模板生成 demo(确定性生成器)」。 + - 生成/保存/编译三路由由「透传 JSON 死胡同」改为 **PRG 重定向回工作台 + 详情页状态横幅**(`workbenchNotice`);编译措辞守住 S2 边界。 + - 401/403 → 跳 `/login`;空状态「创建首个版本」原生表单兜底(闭合建版死路)。 + +**沙箱天花板**:以上端到端真跑通仍需真实 Postgres(项目/版本/草稿落库、生成/保存/编译)。本沙箱仅验证代理/数据加载器/页面纯函数 + 类型 + lint + Next build;不在沙箱宣称端到端通过。 + +## 关键架构发现(非显然,后续必读) + +1. **预览加载器 fixture-only**:`apps/web/src/components/runtime/WebGameRuntime.ts` 的 `getPreviewPackageSource(versionId)` 只认 `simulation-fixture-v1`,真实 versionId 返回 error。后端 **game-package 模块无 HTTP 端点**,不存在「按版本取可运行 web 包」的接口。 +2. **S2 边界(有测试强制)**:`MainCreationAgentChat.test.tsx` 用 `renderToStaticMarkup` 断言编译结果 HTML **不得含** `playable/published/package generated/runtime preview`——编译刻意止于 validated GameIR,工作台不承诺可玩/预览。 +3. **更深一层发现(决定 M1-T5 不可作为有意义功能交付)**:除交付缺口外,`compileGameLogicModule` 实际产出的是**桩**——`renderLogicModuleSource`(`apps/api/src/modules/game-logic/index.ts:160-210`)的 `render()` 只画一行 `templateType` 文字,`update/handleInput` 近乎空操作;真正可玩逻辑是 `WebGameRuntime.tsx:502` 的**手写 fixture**,非由 GameConfig 生成。即便补交付端点,真实版本预览也只显示一行文字,非可玩游戏。真正「试玩你的游戏」需 **GameConfig→真实仿真解释器**(产品运行时核心史诗)。 + +## M1-T5 决策与落地(2026-06-06,用户已确认 = 路径 A) + +审阅版:`docs/agent-specs/2026-06-06-M1T5预览试玩-审阅版.md`(含两处缺口、S2 边界、A/B/C 选项与证据)。 + +**用户选定路径 A:诚实标注 + 延后到 M2**,已落地(沙箱 typecheck/lint/88 测试/build 全绿): +- 工作台详情页(页面层,**不碰 MainCreationAgentChat**,守 S2 边界)加提示:「编译产出校验通过的 GameIR;按本版本的可玩预览为后续能力(M2)」。 +- 项目列表页加**明确标注的平台可玩示例 demo 入口** → `/preview/simulation-fixture-v1`,注明「平台示例,非你的项目」,给试玩诚实可达入口,避免手搓 URL 孤儿。 +- 未改 S2 边界测试、未造跳转孤儿、未伪造可玩。 + +**真实每版本预览 = M2 独立史诗**,含两处正交缺口:① GameConfig→真实仿真解释器(核心,决定可玩性);② 后端「按版本取 web 包」交付端点 + 加载器按版本拉取。届时再定解释器形态(可移植解释器 vs 编译 .mjs 动态 import)与 S2 边界落点(页面层引导 vs 改组件)。 + +## 下一步 + +- 端到端验收需在具备 Postgres 的真实环境跑通 M0/M1 流程并留证据。 +- M2 启动时先做 M1-T5 审阅版 §7 的两项技术选择(解释器形态、S2 边界落点),再实现真实预览。