This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse } from "../../../../../lib/http";
|
||||
import { projectRoot, touchProject } from "../../../../../lib/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ projectId: string; file: string }> }) {
|
||||
try {
|
||||
const { projectId, file } = await context.params;
|
||||
if (file !== "minitool.zip") throw new Error("Unknown artifact");
|
||||
await touchProject(projectId);
|
||||
const data = await fs.readFile(path.join(projectRoot(projectId), "artifacts", file));
|
||||
return new NextResponse(data, { headers: { "Content-Type": "application/zip", "Content-Disposition": `attachment; filename="${projectId}-minitool.zip"` } });
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cleanupIdleNodeModules } from "../../../../lib/cleanup";
|
||||
import { errorResponse } from "../../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const idleHours = body.idleHours === undefined ? undefined : Number(body.idleHours);
|
||||
if (idleHours !== undefined && (!Number.isFinite(idleHours) || idleHours < 0)) throw new Error("idleHours must be a non-negative number");
|
||||
return NextResponse.json(await cleanupIdleNodeModules(idleHours));
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildProject } from "../../../../../lib/build";
|
||||
import { errorResponse } from "../../../../../lib/http";
|
||||
import { touchProject } from "../../../../../lib/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
await touchProject(id);
|
||||
const result = await buildProject(id);
|
||||
return NextResponse.json({ report: result.report, previewUrl: `/api/projects/${id}/preview/index.html` });
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { installProjectDependencies } from "../../../../../../../lib/build";
|
||||
import { errorResponse } from "../../../../../../../lib/http";
|
||||
import { touchProject } from "../../../../../../../lib/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
await touchProject(id);
|
||||
return NextResponse.json(await installProjectDependencies(id));
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { errorResponse } from "../../../../../../lib/http";
|
||||
import { readProjectFile, writeProjectFiles } from "../../../../../../lib/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type Context = { params: Promise<{ id: string; path: string[] }> };
|
||||
|
||||
export async function GET(_: NextRequest, context: Context) {
|
||||
try {
|
||||
const params = await context.params;
|
||||
return NextResponse.json({ content: await readProjectFile(params.id, params.path) });
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Context) {
|
||||
try {
|
||||
const params = await context.params;
|
||||
const body = await request.json();
|
||||
if (typeof body.content !== "string") throw new Error("content must be a string");
|
||||
const project = await writeProjectFiles(params.id, [{ path: params.path.join("/"), content: body.content }], body.revision);
|
||||
return NextResponse.json({ project });
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { packageProject } from "../../../../../lib/build";
|
||||
import { errorResponse } from "../../../../../lib/http";
|
||||
import { touchProject } from "../../../../../lib/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
await touchProject(id);
|
||||
const result = await packageProject(id);
|
||||
return NextResponse.json({ report: result.report, artifactUrl: result.zipPath ? `/api/artifacts/${id}/minitool.zip` : undefined });
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { errorResponse } from "../../../../../lib/http";
|
||||
import { writeProjectFiles } from "../../../../../lib/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const body = await request.json();
|
||||
if (!Array.isArray(body.changes) || body.changes.some((item: unknown) => !item || typeof (item as { path?: unknown }).path !== "string" || typeof (item as { content?: unknown }).content !== "string")) throw new Error("changes must contain path and content strings");
|
||||
return NextResponse.json({ project: await writeProjectFiles(id, body.changes, body.revision) });
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildProject } from "../../../../../../lib/build";
|
||||
import { errorResponse } from "../../../../../../lib/http";
|
||||
import { projectRoot, touchProject } from "../../../../../../lib/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".woff": "font/woff", ".woff2": "font/woff2" };
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string; path?: string[] }> }) {
|
||||
try {
|
||||
const params = await context.params;
|
||||
await touchProject(params.id);
|
||||
const segments = params.path?.length ? params.path : ["index.html"];
|
||||
const dist = path.join(projectRoot(params.id), "dist");
|
||||
let target = path.resolve(dist, ...segments);
|
||||
if (!target.startsWith(`${dist}${path.sep}`)) throw new Error("Invalid preview path");
|
||||
try { await fs.access(target); } catch { await buildProject(params.id); target = path.resolve(dist, ...segments); }
|
||||
return new NextResponse(await fs.readFile(target), { headers: { "Content-Type": CONTENT_TYPES[path.extname(target)] || "application/octet-stream", "Cache-Control": "no-store" } });
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { errorResponse } from "../../../../lib/http";
|
||||
import { getProject, listFiles } from "../../../../lib/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const [project, files] = await Promise.all([getProject(id), listFiles(id)]);
|
||||
return NextResponse.json({ project, files });
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildProject } from "../../../../../lib/build";
|
||||
import { errorResponse } from "../../../../../lib/http";
|
||||
import { touchProject } from "../../../../../lib/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
await touchProject(id);
|
||||
const result = await buildProject(id);
|
||||
return NextResponse.json({ report: result.report });
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createProject, listProjects } from "../../../lib/storage";
|
||||
import { errorResponse } from "../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
try { return NextResponse.json({ projects: await listProjects() }); }
|
||||
catch (error) { return errorResponse(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
return NextResponse.json({ project: await createProject(body) }, { status: 201 });
|
||||
} catch (error) { return errorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
:root { color-scheme: light; --ink: #25372f; --muted: #7a827e; --line: #dde1dc; --cream: #f6f4ee; --paper: #fffefa; --green: #315542; --red: #ff2442; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; background: var(--cream); color: var(--ink); font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; }
|
||||
button, textarea, input { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
button { cursor: pointer; }
|
||||
.studio-shell { min-height: 100vh; }
|
||||
.topbar { height: 66px; padding: 0 20px; display: flex; align-items: center; justify-content: space-between; background: var(--paper); border-bottom: 1px solid var(--line); }
|
||||
.topbar > div { display: flex; align-items: center; gap: 10px; }
|
||||
.topbar small { color: var(--muted); padding-left: 10px; border-left: 1px solid var(--line); }
|
||||
.logo { width: 31px; height: 31px; display: grid; place-items: center; color: white; background: var(--red); border-radius: 10px; font-weight: 800; }
|
||||
.actions a, .actions button, .panel-head button, .command-grid button { border: 1px solid var(--line); background: white; color: var(--ink); border-radius: 8px; padding: 9px 13px; text-decoration: none; }
|
||||
.actions button, .command-grid .primary { color: white; background: var(--green); border-color: var(--green); }
|
||||
.workspace { display: grid; grid-template-columns: 190px minmax(330px, 1fr) minmax(330px, 430px) 290px; height: calc(100vh - 66px); }
|
||||
.sidebar, .editor-panel, .preview-panel, .inspect-panel { min-width: 0; border-right: 1px solid var(--line); background: var(--paper); }
|
||||
.sidebar, .inspect-panel { padding: 18px 12px; overflow-y: auto; }
|
||||
.section-label { margin: 0 8px 10px; color: #929995; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.files-label { margin-top: 28px; }
|
||||
.project-row, .file-row, .empty-project { width: 100%; border: 0; background: transparent; border-radius: 8px; text-align: left; padding: 10px; color: #526059; }
|
||||
.project-row { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.project-row small { color: #9aa19d; }
|
||||
.project-row.selected, .file-row.selected { background: #e8eee9; color: var(--green); font-weight: 650; }
|
||||
.file-row { padding: 7px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||
.empty-project { border: 1px dashed #bbc4be; text-align: center; }
|
||||
.editor-panel, .preview-panel { display: flex; flex-direction: column; background: #eef0ec; }
|
||||
.panel-head { height: 58px; flex: 0 0 auto; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; background: var(--paper); border-bottom: 1px solid var(--line); }
|
||||
.panel-head div { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.panel-head strong, .panel-head small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.panel-head small { color: var(--muted); font-size: 10px; }
|
||||
.editor-panel textarea { flex: 1; resize: none; border: 0; outline: none; padding: 20px; background: #17201c; color: #dce9df; font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 2; }
|
||||
.statusbar { height: 32px; background: white; border-top: 1px solid var(--line); color: var(--muted); display: flex; align-items: center; gap: 7px; padding: 0 12px; font-size: 11px; }
|
||||
.ok-dot, .error-dot { width: 7px; height: 7px; border-radius: 50%; background: #54a56d; }
|
||||
.error-dot { background: #d95454; }
|
||||
.preview-panel { align-items: center; overflow: auto; }
|
||||
.preview-panel .panel-head { width: 100%; }
|
||||
.phone { position: relative; flex: 0 0 auto; width: 330px; height: 660px; margin: 24px; padding: 12px 8px 8px; background: #202622; border: 4px solid #3b423e; border-radius: 37px; box-shadow: 0 16px 45px rgba(28, 45, 37, .18); }
|
||||
.phone iframe { width: 100%; height: 100%; border: 0; border-radius: 25px; background: white; }
|
||||
.speaker { position: absolute; z-index: 2; top: 6px; left: 50%; width: 55px; height: 5px; transform: translateX(-50%); border-radius: 5px; background: #090b0a; }
|
||||
.inspect-panel { border-right: 0; }
|
||||
.command-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.command-grid button { font-size: 12px; padding: 10px 4px; }
|
||||
.report, .report-placeholder, .agent-note { margin-top: 18px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: #fafbf8; }
|
||||
.report { display: flex; flex-direction: column; gap: 5px; }
|
||||
.report.passed { border-color: #b9d4bf; background: #f2f8f2; }
|
||||
.report.failed { border-color: #e1bcbc; background: #fff7f6; }
|
||||
.report > small, .report article span { color: var(--muted); font-size: 10px; }
|
||||
.report pre { margin: 9px 0; white-space: pre-wrap; font-size: 10px; }
|
||||
.report article { border-top: 1px solid var(--line); padding-top: 10px; margin-top: 6px; }
|
||||
.report article b { display: block; color: #bd4545; font-size: 11px; }
|
||||
.report article p { font-size: 12px; margin: 7px 0; }
|
||||
.report-placeholder { color: var(--muted); text-align: center; font-size: 12px; line-height: 1.7; }
|
||||
.report-placeholder span { display: inline-grid; place-items: center; width: 35px; height: 35px; border: 1px solid #bbccc1; border-radius: 50%; color: var(--green); }
|
||||
.agent-note { margin-top: 26px; background: #eef2ee; border: 0; }
|
||||
.agent-note strong { font-size: 12px; }
|
||||
.agent-note p { color: #68736d; font-size: 11px; line-height: 1.7; }
|
||||
button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
@media (max-width: 1180px) { .workspace { grid-template-columns: 170px minmax(320px, 1fr) 380px; } .inspect-panel { display: none; } }
|
||||
@media (max-width: 820px) { .workspace { grid-template-columns: 150px 1fr; } .preview-panel { display: none; } .topbar small { display: none; } }
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "小红书小工具 Studio",
|
||||
description: "通过 HTTP API 让 Codex 等编码代理创建、预览、检查并打包小红书小工具。",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return <html lang="zh-CN"><body>{children}</body></html>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET(request: Request) {
|
||||
const origin = new URL(request.url).origin;
|
||||
return NextResponse.json({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Rednote Tool Studio API", version: "0.1.0", description: "供 Codex 等外部编码代理创建、修改、构建、校验并打包小红书离线 H5 小工具。写操作使用 revision 进行乐观并发控制。" },
|
||||
servers: [{ url: origin }],
|
||||
paths: {
|
||||
"/api/projects": {
|
||||
get: { operationId: "listProjects", summary: "列出项目", responses: { "200": { description: "项目列表" } } },
|
||||
post: { operationId: "createProject", summary: "从模板创建项目", requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, template: { type: "string", default: "basic" } } } } } }, responses: { "201": { description: "已创建" } } },
|
||||
},
|
||||
"/api/projects/{id}": { get: { operationId: "getProject", summary: "读取项目元数据与文件列表", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "项目详情" } } } },
|
||||
"/api/projects/{id}/files/{path}": {
|
||||
get: { operationId: "readProjectFile", summary: "读取 UTF-8 源文件", parameters: [{ $ref: "#/components/parameters/ProjectId" }, { $ref: "#/components/parameters/FilePath" }], responses: { "200": { description: "文件内容" } } },
|
||||
put: { operationId: "writeProjectFile", summary: "写入单个源文件", parameters: [{ $ref: "#/components/parameters/ProjectId" }, { $ref: "#/components/parameters/FilePath" }], requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/FileWrite" } } } }, responses: { "200": { description: "已保存" }, "409": { description: "revision 冲突" } } },
|
||||
},
|
||||
"/api/projects/{id}/patch": { post: { operationId: "patchProjectFiles", summary: "原子地写入多个文件", parameters: [{ $ref: "#/components/parameters/ProjectId" }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["revision", "changes"], properties: { revision: { type: "integer" }, changes: { type: "array", items: { type: "object", required: ["path", "content"], properties: { path: { type: "string" }, content: { type: "string" } } } } } } } } }, responses: { "200": { description: "已更新" }, "409": { description: "revision 冲突" } } } },
|
||||
"/api/projects/{id}/build": { post: { operationId: "buildProject", summary: "构建 ES2017 / Chrome 61 离线产物", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "构建结果和预览地址" } } } },
|
||||
"/api/projects/{id}/dependencies/install": { post: { operationId: "installProjectDependencies", summary: "根据 package.json 安装项目依赖;禁用生命周期脚本", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "安装日志" } } } },
|
||||
"/api/projects/{id}/validate": { post: { operationId: "validateProject", summary: "构建并运行语义检查与 skill 审计", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "结构化校验报告" } } } },
|
||||
"/api/projects/{id}/package": { post: { operationId: "packageProject", summary: "校验并生成 ZIP", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "校验报告和 artifactUrl;失败时不生成 ZIP" } } } },
|
||||
"/api/projects/{id}/preview/{path}": { get: { operationId: "previewArtifact", summary: "读取预览产物", parameters: [{ $ref: "#/components/parameters/ProjectId" }, { $ref: "#/components/parameters/FilePath" }], responses: { "200": { description: "静态产物" } } } },
|
||||
"/api/maintenance/cleanup": { post: { operationId: "cleanupIdleNodeModules", summary: "清理闲置项目 node_modules", requestBody: { content: { "application/json": { schema: { type: "object", properties: { idleHours: { type: "number", minimum: 0, default: 24 } } } } } }, responses: { "200": { description: "清理结果" } } } },
|
||||
},
|
||||
components: {
|
||||
parameters: {
|
||||
ProjectId: { name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
FilePath: { name: "path", in: "path", required: true, schema: { type: "string" }, description: "项目相对路径,例如 src/App.jsx" },
|
||||
},
|
||||
schemas: {
|
||||
FileWrite: { type: "object", required: ["content", "revision"], properties: { content: { type: "string" }, revision: { type: "integer", description: "来自项目元数据的当前 revision" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Studio from "../components/Studio";
|
||||
|
||||
export default function Home() {
|
||||
return <Studio />;
|
||||
}
|
||||
Reference in New Issue
Block a user