feat(web): add M0 product shell and M1 creator-flow wiring

M0 (product shell / user entry): /login page, app-shell nav with current user + role + logout, real project list (replaces hardcoded demoProjects), create-project + auto first-version, role-aware home entries. Auth uses native-form + 303 PRG redirect + HttpOnly huijing_api_token cookie; new proxies for auth(login/logout/me) and projects(create/version).

M1 (creator flow): surface deterministic-generator demo label; switch prompt/save/compile routes from raw-JSON passthrough to PRG redirect back to workbench + status banner; 401/403 -> /login, empty-state create-version fallback. M1-T5 (per-version playable preview) deferred to M2 (Path A): verified blockers = stub logic compiler + no package-serving endpoint + tested S2 boundary; workbench honestly labels preview as a later capability and links the fixture as a clearly-marked platform sample (no orphan jump, S2 boundary untouched).

Verification (@huijing/web): typecheck + lint + 88 unit tests + next build + git diff --check all green. End-to-end (DB-backed create/generate/save/compile) requires real Postgres and is not claimed verified in-sandbox.

Docs: docs/agent-specs/2026-06-06-MVP用户可试用M0M1-执行版.md (execution plan + status), docs/agent-specs/2026-06-06-M1T5预览试玩-审阅版.md (M1-T5 review/decision), docs/memorys/2026-06-06-MVP可试用M0M1执行版.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-06 01:55:47 +00:00
parent 7c7879cfe8
commit 14748491fe
30 changed files with 1940 additions and 105 deletions

View File

@ -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<Response> {
const built = await buildLoginProxyRequest({ apiBaseUrl: apiBaseUrlFromEnv(), request });
return proxyLoginAndSetSessionCookie(built, {
failureLocation: "/login?error=invalid",
successLocation: "/workbench"
});
}

View File

@ -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<Response> {
const built = buildLogoutProxyRequest({ apiBaseUrl: apiBaseUrlFromEnv(), request });
return proxyLogoutAndClearSessionCookie(built, { redirectLocation: "/login" });
}

View File

@ -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<Response> {
return proxyBuiltRequest(buildMeProxyRequest({ apiBaseUrl: apiBaseUrlFromEnv(), request }));
}

View File

@ -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<Response> {
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` }
);
}

View File

@ -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 = {
}>;
};
// POSTprompt 生成。原生表单提交 → 后端确定性生成器产出草稿 → PRG 重定向回工作台(带 ?generated 标记),
// 页面 reload 后通过 loadWorkbenchProjectData 展示新草稿;失败带 ?error=generate。
export async function POST(request: Request, context: RouteContext): Promise<Response> {
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` }
);
}

View File

@ -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<Response> {
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` }
);
}

View File

@ -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<Resp
})
);
}
// POST详情页空状态「创建首个版本」原生表单提交 → 建 draft version → 303 跳回项目工作台。
export async function POST(request: Request, context: RouteContext): Promise<Response> {
const { projectId } = await context.params;
const projectLocation = `/workbench/${encodeURIComponent(projectId)}`;
return createDraftVersionAndRedirect(
{ apiBaseUrl: apiBaseUrlFromEnv(), projectId, request },
{ failureLocation: `${projectLocation}?error=version`, successLocation: projectLocation }
);
}

View File

@ -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<Response> {
return createProjectThenVersionAndRedirect(
{ apiBaseUrl: apiBaseUrlFromEnv(), request },
{
failureLocation: "/workbench/new?error=create",
projectLocation: (projectId) => `/workbench/${encodeURIComponent(projectId)}`
}
);
}

View File

@ -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();
});
});

View File

@ -9,6 +9,15 @@ type ProxyBuildInput = {
type ProjectProxyBuildInput = Omit<ProxyBuildInput, "versionId">;
// 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<string, unknown>;
@ -19,7 +28,8 @@ export type BuiltApiProxyRequest =
| {
readonly ok: true;
readonly body: Record<string, unknown>;
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-T1auth 代理login / logout / me+ 会话 cookie ============
// 登录:在 4 个 seeded 测试用户中按 email 或 userId 匹配,无需 Bearer token。
export async function buildLoginProxyRequest(input: AuthProxyBuildInput): Promise<BuiltApiProxyRequest> {
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`
};
}
// 写会话 cookieHttpOnly前端 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}`;
}
// 清会话 cookieMax-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<Response> {
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<Response> {
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-T4projects 代理(创建项目 / 创建首个 draft version============
// 创建项目creator 角色专属(后端强制);表单需 titleslug 可选。
export async function buildCreateProjectProxyRequest(input: AuthProxyBuildInput): Promise<BuiltApiProxyRequest> {
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 versionconfigJson 留空对象,后端走 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<Response> {
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<Response> {
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<Response> {
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();

View File

@ -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<typeof fetch>(() =>
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");
});
});

View File

@ -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<CurrentActor | null> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim() !== "";
}

View File

@ -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<string, CSSProperties>;
// 角色中文名(外壳展示用,不参与权限判断;权限由后端 AuthGuard 强制)。
const roleLabels: Record<string, string> = {
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" }, "退出")
)
);
}

View File

@ -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();
});
});

View File

@ -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<string, CSSProperties>;
// 把 ?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" }, "登录")
)
);
}

View File

@ -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([]);
});
});

View File

@ -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<string, CSSProperties>;
// 根据角色集合计算首页入口卡片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 }, "即将开放")
)
)
)
);
}

View File

@ -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();
});
});

View File

@ -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<string, CSSProperties>;
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 }, "← 返回项目列表")
);
}

View File

@ -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();
});
});

View File

@ -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<string, CSSProperties>;
// 把 ?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 }, "← 返回项目列表")
);
}

View File

@ -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<string, CSSProperties>;
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}`)
);
}

View File

@ -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<typeof fetch>(() => 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([]);
});
});

View File

@ -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<ProjectsListData> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim() !== "";
}

View File

@ -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",

View File

@ -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");
});
});

View File

@ -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<br/>已有, 校验通过] --> B{缺口1: 真实逻辑}
B -- 现状: 桩(只画templateType) --> C[可运行逻辑模块]
C --> D{缺口2: 交付}
D -- 现状: 无HTTP端点, 加载器fixture-only --> E[Web 预览运行时<br/>已有, 可跑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真功能** | 先做缺口1GameConfig→真实仿真解释器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 真实预览在结论确认前不实现,避免产出桩/孤儿或破坏受测边界。*

View File

@ -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`S0S5 已合并 + 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 个分节编辑器)与代理 builderprompt / 保存分节 / 编译 / 列版本 / 读草稿均已存在。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/meprojects 代理list/create/get真实项目列表替换 demoProjects创建项目 UI + 自动建首版;基于角色的入口可见性。
- M1demo 标签表面化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 转发 |
| 真实 PostgresM0/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=<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)
→ 确定性生成器产出 drafttemplateVersion=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 → 生成 → 草稿落库 → 分节保存 → 编译 → 预览 端到端。
- RBACcreator 看不到他人项目;非 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 CM0 后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 环境证据,不得混淆。*

View File

@ -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 PASS23 files / 244 tests`2026-06-05-S4Task5运行冒烟.md` |
| S5 mini-game conversion | static_validated 仅import cert = No-Go | 三端 static gate PASSDevTool import smoke 默认写 blocker无 passed evidence |

View File

@ -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"补"产品外壳/用户入口"这条与 S6S8 正交的线。用户已确认两项决策:
- **范围 = 只做 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与代理 builderprompt/保存分节/编译/列版本/读草稿)均已存在;`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 环境验证**,不得在沙箱伪造为通过。
## 任务骨架
M0M0-T1 auth 代理+cookie → T2 登录页 / T3 外壳M0-T4 projects 代理 → T5 真实列表 / T6 创建项目+自动建首版。
M1T1 demo 标签 → T2 生成状态 → T3 分节编辑保存 → T4 编译 → T5 预览入口T6 失败兜底。
并行起步点:{M0-T1, M0-T4}(纯增量代理 builder可单测
## 待确认项(非阻塞)
登录页形态(下拉 seeded 邮箱 vs 自由输入、operator/player 入口(占位"即将开放" vs 隐藏)、建版失败处理(保留项目+重试 vs 停留)。
## 实现进展2026-06-06git 待提交)
已实现并通过沙箱验证(`@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 边界落点),再实现真实预览。