feat: scaffold rednote content projects

This commit is contained in:
2026-10-04 20:06:21 +08:00
parent 46c4d06db7
commit 0690ecf050
13 changed files with 327 additions and 220 deletions
+103 -61
View File
@@ -1,9 +1,25 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
type Project = { id: string; name: string; revision: number; updatedAt: string; nodeModulesCleanedAt?: string };
type Report = { status: "passed" | "failed"; skillVersion: string; auditOutput: string; issues: Array<{ severity: string; code: string; file: string; line?: number; message: string; suggestion: string }>; limits: { zipBytes?: number; unpackedBytes: number } };
type Recipe = {
positioning: string;
audience: string;
tone: string;
contentFormat: string;
titleMethod: string;
bodyStructure: string;
defaultTags: string[];
};
type Project = {
id: string;
name: string;
description: string;
recipe: Recipe;
revision: number;
updatedAt: string;
};
async function api<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { ...init, headers: { "Content-Type": "application/json", ...(init?.headers || {}) } });
@@ -12,90 +28,116 @@ async function api<T>(url: string, init?: RequestInit): Promise<T> {
return data;
}
const emptyRecipe: Recipe = {
positioning: "",
audience: "",
tone: "真诚自然",
contentFormat: "干货教程",
titleMethod: "",
bodyStructure: "",
defaultTags: [],
};
export default function Studio() {
const [projects, setProjects] = useState<Project[]>([]);
const [selected, setSelected] = useState<Project | null>(null);
const [files, setFiles] = useState<string[]>([]);
const [activeFile, setActiveFile] = useState("src/App.jsx");
const [content, setContent] = useState("");
const [report, setReport] = useState<Report | null>(null);
const [draft, setDraft] = useState<Project | null>(null);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("连接已就绪");
const [previewKey, setPreviewKey] = useState(0);
const [message, setMessage] = useState("项目是最高层级:一套项目,对应一种稳定的内容生成方式。");
const loadProjects = useCallback(async () => {
const data = await api<{ projects: Project[] }>("/api/projects");
setProjects(data.projects);
if (!selected && data.projects[0]) setSelected(data.projects[0]);
}, [selected]);
setSelected((current) => current || data.projects[0] || null);
}, []);
useEffect(() => { void loadProjects(); }, [loadProjects]);
useEffect(() => {
if (!selected) return;
void api<{ files: string[] }>(`/api/projects/${selected.id}`).then((data) => setFiles(data.files));
}, [selected]);
useEffect(() => {
if (!selected || !activeFile) return;
void api<{ content: string }>(`/api/projects/${selected.id}/files/${activeFile}`).then((data) => setContent(data.content));
}, [selected, activeFile]);
const previewUrl = useMemo(() => selected ? `/api/projects/${selected.id}/preview/index.html?v=${previewKey}` : "about:blank", [selected, previewKey]);
useEffect(() => { setDraft(selected ? structuredClone(selected) : null); }, [selected]);
async function createProject() {
setBusy("create");
try {
const data = await api<{ project: Project }>("/api/projects", { method: "POST", body: JSON.stringify({ name: `灵感小工具 ${projects.length + 1}`, template: "basic" }) });
setSelected(data.project); setReport(null); setMessage("项目已创建"); await loadProjects();
} catch (error) { setMessage((error as Error).message); } finally { setBusy(""); }
const data = await api<{ project: Project }>("/api/projects", {
method: "POST",
body: JSON.stringify({ name: `内容项目 ${projects.length + 1}` }),
});
setProjects((items) => [data.project, ...items]);
setSelected(data.project);
setMessage("项目骨架已创建,请完善这类内容的统一生成方法。");
} catch (error) { setMessage((error as Error).message); }
finally { setBusy(""); }
}
async function save() {
if (!selected) return;
async function saveProject() {
if (!draft) return;
setBusy("save");
try {
const data = await api<{ project: Project }>(`/api/projects/${selected.id}/files/${activeFile}`, { method: "PUT", body: JSON.stringify({ content, revision: selected.revision }) });
setSelected(data.project); setMessage(`已保存 · revision ${data.project.revision}`);
} catch (error) { setMessage((error as Error).message); } finally { setBusy(""); }
const data = await api<{ project: Project }>(`/api/projects/${draft.id}`, {
method: "PUT",
body: JSON.stringify({ ...draft, revision: draft.revision }),
});
setSelected(data.project);
setProjects((items) => items.map((item) => item.id === data.project.id ? data.project : item));
setMessage(`项目方法已保存 · r${data.project.revision}`);
} catch (error) { setMessage((error as Error).message); }
finally { setBusy(""); }
}
async function run(action: "build" | "validate" | "package") {
if (!selected) return;
setBusy(action);
try {
const data = await api<{ report: Report; artifactUrl?: string }>(`/api/projects/${selected.id}/${action}`, { method: "POST", body: "{}" });
setReport(data.report); setPreviewKey((value) => value + 1);
setMessage(data.report.status === "passed" ? `${action} 已通过` : `${action} 发现 ${data.report.issues.length} 个问题`);
if (data.artifactUrl) setMessage(`ZIP 已生成:${data.artifactUrl}`);
} catch (error) { setMessage((error as Error).message); } finally { setBusy(""); }
function updateRecipe(key: keyof Recipe, value: string | string[]) {
setDraft((current) => current ? { ...current, recipe: { ...emptyRecipe, ...current.recipe, [key]: value } } : current);
}
return <main className="studio-shell">
<header className="topbar">
<div><span className="logo">R</span><strong>Rednote Tool Studio</strong><small>Agent-first 小工具工作台</small></div>
<div className="actions"><a href="/openapi.json" target="_blank">OpenAPI</a><button onClick={createProject} disabled={Boolean(busy)}>+ 新建项目</button></div>
<div className="brand"><span className="logo">R</span><div><strong>Rednote Content Studio</strong><small>小红书内容生成器 · 起步结构</small></div></div>
<button className="primary" onClick={createProject} disabled={Boolean(busy)}>+ 新建项目</button>
</header>
<section className="workspace">
<div className="workspace">
<aside className="sidebar">
<p className="section-label">项目</p>
{projects.length === 0 && <button className="empty-project" onClick={createProject}>创建第一个小工具</button>}
{projects.map((project) => <button key={project.id} className={`project-row ${selected?.id === project.id ? "selected" : ""}`} onClick={() => { setSelected(project); setReport(null); }}><span>{project.name}</span><small>r{project.revision}</small></button>)}
{selected && <><p className="section-label files-label">文件</p>{files.map((file) => <button key={file} className={`file-row ${activeFile === file ? "selected" : ""}`} onClick={() => setActiveFile(file)}>{file}</button>)}</>}
<div className="section-head"><span>项目</span><small>最高层级</small></div>
{projects.length === 0 && <button className="empty-project" onClick={createProject}>创建第一个内容项目</button>}
{projects.map((project) => <button key={project.id} className={`project-row ${selected?.id === project.id ? "selected" : ""}`} onClick={() => setSelected(project)}>
<span>{project.name}</span><small>r{project.revision}</small>
</button>)}
<div className="sidebar-note"><b>项目是什么?</b><p>项目代表一类用同样方式生成的内容,例如“每周护肤成分拆解”或“上海周末探店”。</p></div>
</aside>
<section className="editor-panel">
<div className="panel-head"><div><strong>{activeFile}</strong><small>{selected ? selected.id : "未选择项目"}</small></div><button onClick={save} disabled={!selected || Boolean(busy)}>{busy === "save" ? "保存中…" : "保存"}</button></div>
<textarea value={content} onChange={(event) => setContent(event.target.value)} spellCheck={false} disabled={!selected} aria-label="文件编辑器" />
<div className="statusbar"><span className={report?.status === "failed" ? "error-dot" : "ok-dot"} />{message}</div>
</section>
<section className="preview-panel">
<div className="panel-head"><div><strong>手机预览</strong><small>离线 H5 产物</small></div><button onClick={() => run("build")} disabled={!selected || Boolean(busy)}>{busy === "build" ? "构建中…" : "构建并刷新"}</button></div>
<div className="phone"><div className="speaker" /><iframe src={previewUrl} title="小工具预览" /></div>
</section>
<aside className="inspect-panel">
<p className="section-label">交付门禁</p>
<div className="command-grid"><button onClick={() => run("validate")} disabled={!selected || Boolean(busy)}>检查当前产物</button><button className="primary" onClick={() => run("package")} disabled={!selected || Boolean(busy)}>生成 ZIP</button></div>
{report ? <div className={`report ${report.status}`}><strong>{report.status === "passed" ? "检查通过" : "需要修复"}</strong><small>skill {report.skillVersion} · {(report.limits.unpackedBytes / 1024).toFixed(1)} KiB</small><pre>{report.auditOutput}</pre>{report.issues.map((issue) => <article key={`${issue.code}-${issue.file}-${issue.line || 0}`}><b>{issue.code}</b><span>{issue.file}{issue.line ? `:${issue.line}` : ""}</span><p>{issue.message}</p><small>{issue.suggestion}</small></article>)}</div> : <div className="report-placeholder"><span>✓</span><p>构建后显示结构化检查结果、修复建议和 skill 审计摘要。</p></div>}
<div className="agent-note"><strong>为 Codex 设计</strong><p>通过文件 API 修改代码,用 revision 防止覆盖,再调用 build、validate 和 package。完整契约见 OpenAPI。</p></div>
</aside>
</section>
{!draft ? <section className="welcome">
<span className="welcome-mark">✦</span><h1>先创建一个项目</h1><p>为一类内容定义固定的定位、受众、语气和结构。</p><button className="primary" onClick={createProject}>创建项目</button>
</section> : <>
<section className="project-editor">
<div className="page-title"><div><span>项目配置</span><h1>{draft.name}</h1><p>{draft.description}</p></div><button className="primary" onClick={saveProject} disabled={Boolean(busy)}>{busy === "save" ? "保存中…" : "保存项目方法"}</button></div>
<div className="form-card">
<h2>基本信息</h2>
<label>项目名称<input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></label>
<label>项目说明<textarea rows={2} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></label>
</div>
<div className="form-card">
<div className="card-title"><div><h2>统一生成方法</h2><p>项目下的每篇内容都会继承这些规则。</p></div><span className="status-pill">结构已建立</span></div>
<div className="form-grid">
<label>内容定位<textarea rows={3} value={draft.recipe?.positioning || ""} onChange={(event) => updateRecipe("positioning", event.target.value)} placeholder="这个项目持续提供什么价值?" /></label>
<label>目标受众<textarea rows={3} value={draft.recipe?.audience || ""} onChange={(event) => updateRecipe("audience", event.target.value)} placeholder="内容主要写给谁?" /></label>
<label>表达语气<select value={draft.recipe?.tone || "真诚自然"} onChange={(event) => updateRecipe("tone", event.target.value)}><option>真诚自然</option><option>专业可信</option><option>轻松活泼</option><option>温柔治愈</option><option>犀利直接</option></select></label>
<label>内容形式<select value={draft.recipe?.contentFormat || "干货教程"} onChange={(event) => updateRecipe("contentFormat", event.target.value)}><option>干货教程</option><option>清单合集</option><option>经验故事</option><option>产品种草</option><option>测评对比</option></select></label>
<label>标题方法<textarea rows={3} value={draft.recipe?.titleMethod || ""} onChange={(event) => updateRecipe("titleMethod", event.target.value)} placeholder="标题采用什么固定方法?" /></label>
<label>正文结构<textarea rows={3} value={draft.recipe?.bodyStructure || ""} onChange={(event) => updateRecipe("bodyStructure", event.target.value)} placeholder="例如:痛点开场 → 3 个步骤 → 总结" /></label>
</div>
<label>默认标签<input value={(draft.recipe?.defaultTags || []).join(",")} onChange={(event) => updateRecipe("defaultTags", event.target.value.split(/[,,]/).map((item) => item.trim()).filter(Boolean))} placeholder="经验分享,实用干货" /></label>
</div>
</section>
<aside className="next-panel">
<span className="eyebrow">下一阶段</span><h2>内容生成区</h2><p>本次只搭建项目层和生成方法,不开始实际文案生成。</p>
<div className="future-step"><b>01</b><span><strong>输入选题简报</strong><small>主题、角度、要点和素材</small></span></div>
<div className="future-step"><b>02</b><span><strong>按项目方法生成</strong><small>标题、正文与标签</small></span></div>
<div className="future-step"><b>03</b><span><strong>保存内容草稿</strong><small>编辑、审核与导出</small></span></div>
<button disabled>生成内容(待接入)</button>
</aside>
</>}
</div>
<footer className="statusbar"><span />{message}</footer>
</main>;
}