Files
rednote-tool-studio/components/Studio.tsx
T

144 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useState } from "react";
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 || {}) } });
const data = await response.json();
if (!response.ok) throw new Error(data.error || `请求失败:${response.status}`);
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 [draft, setDraft] = useState<Project | null>(null);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("项目是最高层级:一套项目,对应一种稳定的内容生成方式。");
const loadProjects = useCallback(async () => {
const data = await api<{ projects: Project[] }>("/api/projects");
setProjects(data.projects);
setSelected((current) => current || data.projects[0] || null);
}, []);
useEffect(() => { void loadProjects(); }, [loadProjects]);
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}` }),
});
setProjects((items) => [data.project, ...items]);
setSelected(data.project);
setMessage("项目骨架已创建,请完善这类内容的统一生成方法。");
} catch (error) { setMessage((error as Error).message); }
finally { setBusy(""); }
}
async function saveProject() {
if (!draft) return;
setBusy("save");
try {
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(""); }
}
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 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>
<div className="workspace">
<aside className="sidebar">
<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>
{!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>;
}