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:
parent
7c7879cfe8
commit
14748491fe
11
apps/web/src/app/api/auth/login/route.ts
Normal file
11
apps/web/src/app/api/auth/login/route.ts
Normal 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"
|
||||
});
|
||||
}
|
||||
7
apps/web/src/app/api/auth/logout/route.ts
Normal file
7
apps/web/src/app/api/auth/logout/route.ts
Normal 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" });
|
||||
}
|
||||
6
apps/web/src/app/api/auth/me/route.ts
Normal file
6
apps/web/src/app/api/auth/me/route.ts
Normal 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 }));
|
||||
}
|
||||
@ -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` }
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<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` }
|
||||
);
|
||||
}
|
||||
|
||||
@ -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` }
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
13
apps/web/src/app/api/workbench/projects/route.ts
Normal file
13
apps/web/src/app/api/workbench/projects/route.ts
Normal 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)}`
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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-T1:auth 代理(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`
|
||||
};
|
||||
}
|
||||
|
||||
// 写会话 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<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-T4:projects 代理(创建项目 / 创建首个 draft version)============
|
||||
|
||||
// 创建项目:creator 角色专属(后端强制);表单需 title,slug 可选。
|
||||
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 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<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();
|
||||
|
||||
61
apps/web/src/app/auth-data.test.ts
Normal file
61
apps/web/src/app/auth-data.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
74
apps/web/src/app/auth-data.ts
Normal file
74
apps/web/src/app/auth-data.ts
Normal 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() !== "";
|
||||
}
|
||||
@ -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" }, "退出")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
15
apps/web/src/app/login/page.test.ts
Normal file
15
apps/web/src/app/login/page.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
106
apps/web/src/app/login/page.tsx
Normal file
106
apps/web/src/app/login/page.tsx
Normal 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" }, "登录")
|
||||
)
|
||||
);
|
||||
}
|
||||
30
apps/web/src/app/page.test.ts
Normal file
30
apps/web/src/app/page.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@ -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 }, "即将开放")
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
32
apps/web/src/app/workbench/[projectId]/page.test.ts
Normal file
32
apps/web/src/app/workbench/[projectId]/page.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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 }, "← 返回项目列表")
|
||||
);
|
||||
}
|
||||
|
||||
11
apps/web/src/app/workbench/new/page.test.ts
Normal file
11
apps/web/src/app/workbench/new/page.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
79
apps/web/src/app/workbench/new/page.tsx
Normal file
79
apps/web/src/app/workbench/new/page.tsx
Normal 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 }, "← 返回项目列表")
|
||||
);
|
||||
}
|
||||
@ -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}`)
|
||||
);
|
||||
}
|
||||
|
||||
68
apps/web/src/app/workbench/projects-data.test.ts
Normal file
68
apps/web/src/app/workbench/projects-data.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
77
apps/web/src/app/workbench/projects-data.ts
Normal file
77
apps/web/src/app/workbench/projects-data.ts
Normal 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() !== "";
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
68
docs/agent-specs/2026-06-06-M1T5预览试玩-审阅版.md
Normal file
68
docs/agent-specs/2026-06-06-M1T5预览试玩-审阅版.md
Normal 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(真功能)** | 先做缺口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 真实预览在结论确认前不实现,避免产出桩/孤儿或破坏受测边界。*
|
||||
263
docs/agent-specs/2026-06-06-MVP用户可试用M0M1-执行版.md
Normal file
263
docs/agent-specs/2026-06-06-MVP用户可试用M0M1-执行版.md
Normal 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`(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=<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 环境证据,不得混淆。*
|
||||
@ -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 |
|
||||
|
||||
70
docs/memorys/2026-06-06-MVP可试用M0M1执行版.md
Normal file
70
docs/memorys/2026-06-06-MVP可试用M0M1执行版.md
Normal 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"补"产品外壳/用户入口"这条与 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 边界落点),再实现真实预览。
|
||||
Loading…
x
Reference in New Issue
Block a user