Files
rednote-tool-studio/components/Studio.tsx
T
2026-09-17 21:54:33 +08:00

102 lines
7.0 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, useMemo, 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 } };
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;
}
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 [busy, setBusy] = useState("");
const [message, setMessage] = useState("连接已就绪");
const [previewKey, setPreviewKey] = useState(0);
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]);
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]);
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(""); }
}
async function save() {
if (!selected) 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(""); }
}
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(""); }
}
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>
</header>
<section 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>)}</>}
</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>
</main>;
}