512 lines
30 KiB
TypeScript
512 lines
30 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
ArrowRightIcon,
|
||
BuildingsIcon,
|
||
CheckIcon,
|
||
CircleNotchIcon,
|
||
CubeIcon,
|
||
DownloadSimpleIcon,
|
||
GearSixIcon,
|
||
ImageIcon,
|
||
MagicWandIcon,
|
||
PaperPlaneTiltIcon,
|
||
PencilSimpleIcon,
|
||
PlusIcon,
|
||
SparkleIcon,
|
||
UploadSimpleIcon,
|
||
WarningCircleIcon,
|
||
} from "@phosphor-icons/react";
|
||
import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
import { SettingsCenter } from "@/components/settings-center";
|
||
import type { DesignBrief, ProjectSnapshot, RenderAsset, WorkflowStageId } from "@/types/workflow";
|
||
import type { SettingsReadiness } from "@/types/settings";
|
||
|
||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
|
||
|
||
const EMPTY_BRIEF: DesignBrief = {
|
||
residents: "",
|
||
lifestyle: [],
|
||
focus_rooms: [],
|
||
preferred_styles: [],
|
||
preferred_colors: [],
|
||
disliked_elements: [],
|
||
must_keep: [],
|
||
budget_level: "适中",
|
||
additional_notes: "",
|
||
completed: false,
|
||
};
|
||
|
||
const STAGES: Array<{ ids: Array<WorkflowStageId | "failed">; label: string; detail: string }> = [
|
||
{ ids: ["uploaded", "region_selection"], label: "导入图纸", detail: "找到正确户型" },
|
||
{ ids: ["plan_review", "blockout"], label: "理解空间", detail: "房间、动线、采光" },
|
||
{ ids: ["style_brief"], label: "说清需求", detail: "生活方式与偏好" },
|
||
{ ids: ["direction_selection"], label: "选择方向", detail: "三套 Style DNA" },
|
||
{ ids: ["render_review"], label: "生成效果", detail: "真实模型出图" },
|
||
{ ids: ["editing"], label: "多轮修改", detail: "保留版本继续改" },
|
||
{ ids: ["completed"], label: "完成方案", detail: "查看与导出" },
|
||
];
|
||
|
||
const LIFESTYLE_OPTIONS = ["经常下厨", "在家办公", "亲子活动", "朋友聚会", "重视收纳", "养宠物"];
|
||
const STYLE_OPTIONS = ["现代简约", "原木自然", "中古现代", "侘寂", "现代法式", "轻工业"];
|
||
const COLOR_OPTIONS = ["黑白灰", "自然木色", "低饱和彩色", "冷色调", "暖色调", "深色氛围"];
|
||
|
||
function stageIndex(stage?: WorkflowStageId | "failed") {
|
||
if (!stage) return 0;
|
||
const index = STAGES.findIndex((item) => item.ids.includes(stage));
|
||
return index < 0 ? 0 : index;
|
||
}
|
||
|
||
function PlanPreview({ project, selectable, onSelect }: {
|
||
project: ProjectSnapshot;
|
||
selectable?: boolean;
|
||
onSelect?: (id: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="product-plan-frame">
|
||
{/* eslint-disable-next-line @next/next/no-img-element -- local authenticated project preview. */}
|
||
<img src={`${API_BASE}/v1/projects/${project.project_id}/preview`} alt={`${project.name} 户型图`} />
|
||
{selectable ? (
|
||
<div className="product-region-layer">
|
||
{project.plan.regions.map((region, index) => {
|
||
const [x0, y0, x1, y1] = region.bounds;
|
||
return (
|
||
<button
|
||
key={region.id}
|
||
type="button"
|
||
className={region.recommended ? "recommended" : ""}
|
||
style={{ left: `${x0 * 100}%`, top: `${y0 * 100}%`, width: `${(x1 - x0) * 100}%`, height: `${(y1 - y0) * 100}%` }}
|
||
onClick={() => onSelect?.(region.id)}
|
||
>
|
||
<strong>候选 {index + 1}</strong>
|
||
<span>{Math.round(region.confidence * 100)}%{region.recommended ? ",建议" : ""}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ChoiceGroup({ label, options, values, onChange }: {
|
||
label: string;
|
||
options: string[];
|
||
values: string[];
|
||
onChange: (next: string[]) => void;
|
||
}) {
|
||
return (
|
||
<fieldset className="choice-group">
|
||
<legend>{label}</legend>
|
||
<div>
|
||
{options.map((option) => (
|
||
<button
|
||
key={option}
|
||
type="button"
|
||
className={values.includes(option) ? "selected" : ""}
|
||
onClick={() => onChange(values.includes(option) ? values.filter((item) => item !== option) : [...values, option])}
|
||
>
|
||
{values.includes(option) ? <CheckIcon size={13} weight="bold" /> : null}{option}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</fieldset>
|
||
);
|
||
}
|
||
|
||
export function WorkflowStudio() {
|
||
const [project, setProject] = useState<ProjectSnapshot | null>(null);
|
||
const [readiness, setReadiness] = useState<SettingsReadiness | null>(null);
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [busy, setBusy] = useState("");
|
||
const [error, setError] = useState("");
|
||
const [brief, setBrief] = useState<DesignBrief>(EMPTY_BRIEF);
|
||
const [grossArea, setGrossArea] = useState("");
|
||
const [ceilingHeight, setCeilingHeight] = useState("2800");
|
||
const [roomNames, setRoomNames] = useState("");
|
||
const [renderRoom, setRenderRoom] = useState("客餐厅");
|
||
const [renderView, setRenderView] = useState("入口看向客厅");
|
||
const [activeRenderId, setActiveRenderId] = useState("");
|
||
const [editInstruction, setEditInstruction] = useState("");
|
||
const [chatDraft, setChatDraft] = useState("");
|
||
const [chatReply, setChatReply] = useState("");
|
||
const fileInput = useRef<HTMLInputElement>(null);
|
||
|
||
const activeStage = stageIndex(project?.stage);
|
||
const selectedDirection = useMemo(
|
||
() => project?.directions.find((item) => item.id === project.style.selected_direction_id) ?? null,
|
||
[project],
|
||
);
|
||
const activeRender = useMemo<RenderAsset | null>(() => {
|
||
if (!project?.renders.length) return null;
|
||
return project.renders.find((item) => item.id === activeRenderId) ?? project.renders.at(-1) ?? null;
|
||
}, [activeRenderId, project]);
|
||
|
||
function applyProject(next: ProjectSnapshot) {
|
||
setProject(next);
|
||
setBrief(next.brief ?? EMPTY_BRIEF);
|
||
setGrossArea(next.plan.gross_area_sqm ? String(next.plan.gross_area_sqm) : "");
|
||
setCeilingHeight(String(next.plan.ceiling_height_mm || 2800));
|
||
setRoomNames(next.plan.structure?.rooms?.map((room) => room.name).join("、") ?? "");
|
||
if (next.plan.structure?.rooms?.[0]?.name) setRenderRoom(next.plan.structure.rooms[0].name);
|
||
if (next.renders.length) setActiveRenderId(next.renders[next.renders.length - 1].id);
|
||
}
|
||
|
||
useEffect(() => {
|
||
async function boot() {
|
||
try {
|
||
const [readinessResponse, projectsResponse] = await Promise.all([
|
||
fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" }),
|
||
fetch(`${API_BASE}/v1/projects`, { cache: "no-store" }),
|
||
]);
|
||
if (readinessResponse.ok) {
|
||
const current = await readinessResponse.json() as SettingsReadiness;
|
||
setReadiness(current);
|
||
if (!current.ready) setSettingsOpen(true);
|
||
}
|
||
if (projectsResponse.ok) {
|
||
const projects = await projectsResponse.json() as ProjectSnapshot[];
|
||
const latest = projects.find((item) => item.project_id !== "demo-apartment");
|
||
if (latest) applyProject(latest);
|
||
}
|
||
} catch {
|
||
setError("无法连接本地服务,请检查 Docker 是否正在运行。");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
void boot();
|
||
}, []);
|
||
|
||
async function apiError(response: Response, fallback: string) {
|
||
try {
|
||
const body = await response.json() as { detail?: string | { message?: string } };
|
||
if (typeof body.detail === "string") return body.detail;
|
||
if (body.detail?.message) return body.detail.message;
|
||
} catch { /* use fallback */ }
|
||
return fallback;
|
||
}
|
||
|
||
async function command(base: ProjectSnapshot, name: string, payload: Record<string, unknown> = {}) {
|
||
const response = await fetch(`${API_BASE}/v1/projects/${base.project_id}/commands`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ command: name, expected_revision: base.revision, payload }),
|
||
});
|
||
if (!response.ok) throw new Error(await apiError(response, "工作流操作失败。"));
|
||
return response.json() as Promise<ProjectSnapshot>;
|
||
}
|
||
|
||
async function upload(file: File) {
|
||
if (!readiness?.ready) {
|
||
setSettingsOpen(true);
|
||
return;
|
||
}
|
||
setBusy("正在读取 PDF 并寻找户型区域");
|
||
setError("");
|
||
const form = new FormData();
|
||
form.append("file", file);
|
||
form.append("project_name", `${file.name.replace(/\.pdf$/i, "")} 风格方案`);
|
||
try {
|
||
const response = await fetch(`${API_BASE}/v1/projects/ingest`, { method: "POST", body: form });
|
||
if (!response.ok) throw new Error(await apiError(response, "图纸上传失败。"));
|
||
applyProject(await response.json() as ProjectSnapshot);
|
||
} catch (reason) {
|
||
setError(reason instanceof Error ? reason.message : "图纸上传失败。");
|
||
} finally {
|
||
setBusy("");
|
||
if (fileInput.current) fileInput.current.value = "";
|
||
}
|
||
}
|
||
|
||
function onFile(event: ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0];
|
||
if (file) void upload(file);
|
||
}
|
||
|
||
async function chooseRegion(id: string) {
|
||
if (!project) return;
|
||
setBusy("正在建立目标户型");
|
||
setError("");
|
||
try { applyProject(await command(project, "select_region", { region_id: id })); }
|
||
catch (reason) { setError(reason instanceof Error ? reason.message : "选择失败。"); }
|
||
finally { setBusy(""); }
|
||
}
|
||
|
||
async function analyzePlan() {
|
||
if (!project) return;
|
||
setBusy("空间模型正在识别房间、动线与采光");
|
||
setError("");
|
||
try {
|
||
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/spatial-analysis`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ gross_area_sqm: grossArea ? Number(grossArea) : null }),
|
||
});
|
||
if (!response.ok) throw new Error(await apiError(response, "空间分析失败。"));
|
||
applyProject(await response.json() as ProjectSnapshot);
|
||
} catch (reason) { setError(reason instanceof Error ? reason.message : "空间分析失败。"); }
|
||
finally { setBusy(""); }
|
||
}
|
||
|
||
async function confirmStructure() {
|
||
if (!project) return;
|
||
setBusy("正在建立空间骨架和推荐视角");
|
||
setError("");
|
||
try {
|
||
const confirmed = project.stage === "blockout"
|
||
? project
|
||
: await command(project, "confirm_plan", {
|
||
gross_area_sqm: grossArea ? Number(grossArea) : null,
|
||
ceiling_height_mm: Number(ceilingHeight) || 2800,
|
||
room_names: roomNames.split(/[、,,\n]/).map((item) => item.trim()).filter(Boolean),
|
||
});
|
||
applyProject(await command(confirmed, "build_blockout"));
|
||
} catch (reason) { setError(reason instanceof Error ? reason.message : "结构确认失败。"); }
|
||
finally { setBusy(""); }
|
||
}
|
||
|
||
async function createDirections() {
|
||
if (!project) return;
|
||
setBusy("设计总监正在生成三套 Style DNA");
|
||
setError("");
|
||
try {
|
||
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/style-directions`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ brief }),
|
||
});
|
||
if (!response.ok) throw new Error(await apiError(response, "风格方向生成失败。"));
|
||
applyProject(await response.json() as ProjectSnapshot);
|
||
} catch (reason) { setError(reason instanceof Error ? reason.message : "风格方向生成失败。"); }
|
||
finally { setBusy(""); }
|
||
}
|
||
|
||
async function selectDirection(id: string) {
|
||
if (!project) return;
|
||
setBusy("正在锁定设计方向");
|
||
setError("");
|
||
try { applyProject(await command(project, "select_direction", { direction_id: id })); }
|
||
catch (reason) { setError(reason instanceof Error ? reason.message : "方向选择失败。"); }
|
||
finally { setBusy(""); }
|
||
}
|
||
|
||
async function generateRender() {
|
||
if (!project?.style.selected_direction_id) return;
|
||
setBusy("真实生图模型正在生成效果图,通常需要 20 秒到 2 分钟");
|
||
setError("");
|
||
try {
|
||
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/renders`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ direction_id: project.style.selected_direction_id, room: renderRoom, view: renderView }),
|
||
});
|
||
if (!response.ok) throw new Error(await apiError(response, "效果图生成失败。"));
|
||
applyProject(await response.json() as ProjectSnapshot);
|
||
} catch (reason) { setError(reason instanceof Error ? reason.message : "效果图生成失败。"); }
|
||
finally { setBusy(""); }
|
||
}
|
||
|
||
async function editRender(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!project || !activeRender || !editInstruction.trim()) return;
|
||
setBusy("正在只修改指定内容,并保持其他区域不变");
|
||
setError("");
|
||
try {
|
||
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/renders/${activeRender.id}/edit`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ instruction: editInstruction.trim() }),
|
||
});
|
||
if (!response.ok) throw new Error(await apiError(response, "效果图修改失败。"));
|
||
applyProject(await response.json() as ProjectSnapshot);
|
||
setEditInstruction("");
|
||
} catch (reason) { setError(reason instanceof Error ? reason.message : "效果图修改失败。"); }
|
||
finally { setBusy(""); }
|
||
}
|
||
|
||
async function chat(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!project || !chatDraft.trim()) return;
|
||
const message = chatDraft.trim();
|
||
setChatDraft("");
|
||
setBusy("设计 Agent 正在理解你的要求");
|
||
try {
|
||
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/chat`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ message }),
|
||
});
|
||
if (!response.ok) throw new Error(await apiError(response, "设计 Agent 暂时无法回复。"));
|
||
const result = await response.json() as { project: ProjectSnapshot; reply: string };
|
||
applyProject(result.project);
|
||
setChatReply(result.reply);
|
||
} catch (reason) { setError(reason instanceof Error ? reason.message : "设计 Agent 暂时无法回复。"); }
|
||
finally { setBusy(""); }
|
||
}
|
||
|
||
async function completeProject() {
|
||
if (!project) return;
|
||
setBusy("正在整理最终方案");
|
||
try { applyProject(await command(project, "complete_project")); }
|
||
catch (reason) { setError(reason instanceof Error ? reason.message : "方案整理失败。"); }
|
||
finally { setBusy(""); }
|
||
}
|
||
|
||
function downloadSummary() {
|
||
if (!project) return;
|
||
const payload = JSON.stringify(project, null, 2);
|
||
const link = document.createElement("a");
|
||
link.href = URL.createObjectURL(new Blob([payload], { type: "application/json" }));
|
||
link.download = `${project.name}-方案记录.json`;
|
||
link.click();
|
||
URL.revokeObjectURL(link.href);
|
||
}
|
||
|
||
function renderWorkspace() {
|
||
if (loading) return <div className="product-empty"><CircleNotchIcon className="spin" size={28} /><h2>正在打开工作台</h2></div>;
|
||
if (!project) return (
|
||
<div className="product-empty product-onboarding">
|
||
<div className="onboarding-art"><BuildingsIcon size={58} weight="thin" /></div>
|
||
<p className="product-kicker">从真实户型开始</p>
|
||
<h1>把一张平面图,变成可反复修改的风格方案</h1>
|
||
<p>上传 PDF 后,系统会带你完成空间理解、风格选择、真实生图和多轮修改。</p>
|
||
<button className="product-button primary" type="button" onClick={() => readiness?.ready ? fileInput.current?.click() : setSettingsOpen(true)}>
|
||
<UploadSimpleIcon size={17} />{readiness?.ready ? "选择 PDF 户型图" : "先完成系统设置"}
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
if (project.stage === "region_selection") return (
|
||
<section className="product-stage">
|
||
<header className="stage-header"><div><p>先选对户型</p><h1>这页图纸里,哪一块是你要设计的家?</h1></div><span>点击图中的候选框</span></header>
|
||
<div className="region-stage"><PlanPreview project={project} selectable onSelect={(id) => void chooseRegion(id)} /><aside><h2>识别结果</h2><strong>{project.plan.regions.length} 个候选区域</strong><p>{project.plan.vector_based ? `矢量 PDF,共读取 ${project.plan.vector_element_count.toLocaleString()} 个图形元素。` : "扫描型图纸,已使用视觉区域检测。"}</p><p>选择只会裁剪工作区域,不会修改原 PDF。</p></aside></div>
|
||
</section>
|
||
);
|
||
|
||
if (project.stage === "plan_review" || project.stage === "blockout") {
|
||
const structure = project.plan.structure;
|
||
return (
|
||
<section className="product-stage">
|
||
<header className="stage-header"><div><p>空间理解</p><h1>先确认布局事实,再谈风格</h1></div><span>{structure.status === "pending" ? "等待 AI 读图" : structure.degraded ? "可编辑草案" : `由 ${structure.model_name} 识别`}</span></header>
|
||
<div className="structure-layout">
|
||
<PlanPreview project={project} />
|
||
<div className="structure-review">
|
||
{structure.status === "pending" ? (
|
||
<div className="analysis-callout"><MagicWandIcon size={28} /><h2>让空间模型读懂这套户型</h2><p>它会判断房间、主要动线、采光和布局机会。结果可以在下一步人工修正。</p><label>建筑面积(可选)<input value={grossArea} onChange={(event) => setGrossArea(event.target.value)} inputMode="decimal" placeholder="例如 108" /></label><button className="product-button primary" type="button" onClick={() => void (project.stage === "blockout" ? confirmStructure() : analyzePlan())}>{project.stage === "blockout" ? "继续进入风格需求" : "开始空间分析"}</button></div>
|
||
) : (
|
||
<>
|
||
<div className={`analysis-summary ${structure.degraded ? "warning" : ""}`}><strong>{structure.summary}</strong><p>{structure.degraded ? "模型失败并不阻塞流程,请直接修改下面的房间名称。" : structure.circulation}</p></div>
|
||
<label className="product-field">房间清单<input value={roomNames} onChange={(event) => setRoomNames(event.target.value)} placeholder="客厅、餐厅、主卧、次卧、厨房、卫生间" /><small>用顿号或逗号分隔,可直接修正 AI 判断</small></label>
|
||
<div className="two-fields"><label className="product-field">建筑面积(㎡)<input value={grossArea} onChange={(event) => setGrossArea(event.target.value)} inputMode="decimal" /></label><label className="product-field">层高(mm)<input value={ceilingHeight} onChange={(event) => setCeilingHeight(event.target.value)} inputMode="numeric" /></label></div>
|
||
<div className="insight-grid"><div><h3>布局机会</h3>{structure.layout_opportunities.map((item) => <p key={item}><CheckIcon size={13} />{item}</p>)}</div><div><h3>需要留意</h3>{structure.risks.map((item) => <p key={item}><WarningCircleIcon size={13} />{item}</p>)}</div></div>
|
||
<button className="product-button primary wide" type="button" onClick={() => void confirmStructure()}>确认结构,进入风格需求 <ArrowRightIcon size={16} /></button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
if (project.stage === "style_brief") return (
|
||
<section className="product-stage brief-stage">
|
||
<header className="stage-header"><div><p>风格需求</p><h1>不需要懂专业术语,说清你怎么生活就够了</h1></div><span>约 2 分钟</span></header>
|
||
<div className="brief-form">
|
||
<label className="product-field wide-field">谁会住在这里?<input value={brief.residents} onChange={(event) => setBrief({ ...brief, residents: event.target.value })} placeholder="例如:两位大人、一个 6 岁孩子和一只猫" /></label>
|
||
<ChoiceGroup label="平时最重要的生活场景" options={LIFESTYLE_OPTIONS} values={brief.lifestyle} onChange={(lifestyle) => setBrief({ ...brief, lifestyle })} />
|
||
<ChoiceGroup label="更接近你的风格" options={STYLE_OPTIONS} values={brief.preferred_styles} onChange={(preferred_styles) => setBrief({ ...brief, preferred_styles })} />
|
||
<ChoiceGroup label="喜欢的色彩倾向" options={COLOR_OPTIONS} values={brief.preferred_colors} onChange={(preferred_colors) => setBrief({ ...brief, preferred_colors })} />
|
||
<div className="brief-grid"><label className="product-field">重点设计空间<input value={brief.focus_rooms.join("、")} onChange={(event) => setBrief({ ...brief, focus_rooms: event.target.value.split(/[、,,]/).filter(Boolean) })} placeholder="客餐厅、主卧" /></label><label className="product-field">预算倾向<select value={brief.budget_level} onChange={(event) => setBrief({ ...brief, budget_level: event.target.value })}><option>尽量节制</option><option>适中</option><option>品质优先</option></select></label></div>
|
||
<div className="brief-grid"><label className="product-field">明确不喜欢<input value={brief.disliked_elements.join("、")} onChange={(event) => setBrief({ ...brief, disliked_elements: event.target.value.split(/[、,,]/).filter(Boolean) })} placeholder="例如:亮面石材、复杂吊顶" /></label><label className="product-field">必须保留<input value={brief.must_keep.join("、")} onChange={(event) => setBrief({ ...brief, must_keep: event.target.value.split(/[、,,]/).filter(Boolean) })} placeholder="例如:旧餐桌、钢琴" /></label></div>
|
||
<label className="product-field wide-field">还有什么想告诉设计师?<textarea value={brief.additional_notes} onChange={(event) => setBrief({ ...brief, additional_notes: event.target.value })} placeholder="例如:希望客厅看起来更松弛,但不要像民宿" /></label>
|
||
<button className="product-button primary form-submit" type="button" onClick={() => void createDirections()}>生成三套风格方向 <SparkleIcon size={16} weight="fill" /></button>
|
||
</div>
|
||
</section>
|
||
);
|
||
|
||
if (project.stage === "direction_selection") return (
|
||
<section className="product-stage directions-stage">
|
||
<header className="stage-header"><div><p>设计方向</p><h1>三套都能落地,但气质不同</h1></div><span>选择后仍可返回修改需求</span></header>
|
||
<div className="direction-grid">
|
||
{project.directions.map((direction) => (
|
||
<article className="direction-card" key={direction.id}>
|
||
<div className="palette-strip">{direction.palette.map((color) => <span key={color.hex} style={{ background: color.hex }} title={`${color.name},${color.role}`} />)}</div>
|
||
<div className="direction-body"><h2>{direction.name}</h2><p>{direction.thesis}</p><div className="keyword-row">{direction.keywords.map((item) => <span key={item}>{item}</span>)}</div><dl><dt>材料</dt><dd>{direction.materials.join("、")}</dd><dt>光线</dt><dd>{direction.lighting}</dd></dl><small>{direction.model_name || "设计策略"}</small><button className="product-button primary wide" type="button" onClick={() => void selectDirection(direction.id)}>用这个方向生成效果图</button></div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
|
||
if (project.stage === "render_review") return (
|
||
<section className="product-stage render-setup">
|
||
<header className="stage-header"><div><p>效果图生成</p><h1>先生成一个关键视角,再决定是否扩展</h1></div><span>将调用真实生图模型</span></header>
|
||
<div className="render-setup-grid">
|
||
<div className="selected-direction-panel"><div className="palette-strip">{selectedDirection?.palette.map((color) => <span key={color.hex} style={{ background: color.hex }} />)}</div><h2>{selectedDirection?.name}</h2><p>{selectedDirection?.thesis}</p><strong>{selectedDirection?.materials.join("、")}</strong></div>
|
||
<div className="render-controls"><label className="product-field">生成哪个空间?<select value={renderRoom} onChange={(event) => setRenderRoom(event.target.value)}>{project.plan.structure.rooms.map((room) => <option key={room.id}>{room.name}</option>)}<option>客餐厅</option><option>主卧</option></select></label><label className="product-field">相机视角<select value={renderView} onChange={(event) => setRenderView(event.target.value)}><option>入口看向客厅</option><option>客厅看向餐厅</option><option>窗边看向室内</option><option>空间广角主视角</option></select></label><div className="generation-note"><CubeIcon size={20} /><p>系统会把户型裁剪图、空间理解和 Style DNA 一起交给当前模型池中的生图模型。</p></div><button className="product-button primary wide" type="button" onClick={() => void generateRender()}><ImageIcon size={17} />生成真实效果图</button></div>
|
||
</div>
|
||
</section>
|
||
);
|
||
|
||
if (project.stage === "editing" || project.stage === "completed") return (
|
||
<section className="product-stage editing-stage">
|
||
<header className="stage-header"><div><p>{project.stage === "completed" ? "方案已完成" : "效果图与修改"}</p><h1>{activeRender?.room},{activeRender?.view}</h1></div><span>{project.renders.length} 个版本</span></header>
|
||
{activeRender ? (
|
||
<div className="render-workspace">
|
||
<div className="render-main">
|
||
{/* eslint-disable-next-line @next/next/no-img-element -- generated project asset. */}
|
||
<img src={`${API_BASE}/v1/projects/${project.project_id}/renders/${activeRender.id}`} alt={`${activeRender.room} ${activeRender.view} 效果图`} />
|
||
<div className="render-meta"><span>{activeRender.model_name}</span><span>{new Date(activeRender.created_at).toLocaleString("zh-CN")}</span></div>
|
||
</div>
|
||
<aside className="render-sidebar"><h2>版本</h2><div className="version-list">{[...project.renders].reverse().map((render, index) => <button className={render.id === activeRender.id ? "active" : ""} type="button" key={render.id} onClick={() => setActiveRenderId(render.id)}><ImageIcon size={16} /><span><strong>版本 {project.renders.length - index}</strong><small>{render.edit_instruction || `${render.room},${render.view}`}</small></span></button>)}</div>{project.stage === "editing" ? <form className="edit-form" onSubmit={editRender}><label>只修改什么?<textarea value={editInstruction} onChange={(event) => setEditInstruction(event.target.value)} placeholder="例如:把沙发改为更低矮的米灰色模块沙发,其他都不变" /></label><button className="product-button primary wide" type="submit" disabled={!editInstruction.trim()}><PencilSimpleIcon size={16} />生成新版本</button><button className="product-button secondary wide" type="button" onClick={() => void completeProject()}>确认当前方案</button></form> : <button className="product-button primary wide" type="button" onClick={downloadSummary}><DownloadSimpleIcon size={16} />导出方案记录</button>}</aside>
|
||
</div>
|
||
) : <div className="product-empty"><ImageIcon size={36} /><h2>还没有效果图</h2></div>}
|
||
</section>
|
||
);
|
||
|
||
return <div className="product-empty"><CubeIcon size={34} /><h2>正在准备下一阶段</h2></div>;
|
||
}
|
||
|
||
return (
|
||
<main className="product-shell">
|
||
<header className="product-topbar">
|
||
<div className="product-brand"><span><BuildingsIcon size={19} weight="fill" /></span><div><strong>住境 AI</strong><small>{project?.name ?? "住宅风格设计工作台"}</small></div></div>
|
||
<div className="product-top-actions">
|
||
{project ? <button className="product-button ghost" type="button" onClick={() => setProject(null)}><PlusIcon size={15} />新方案</button> : null}
|
||
<button className="product-button secondary" type="button" onClick={() => setSettingsOpen(true)}><GearSixIcon size={16} />系统设置</button>
|
||
</div>
|
||
<input ref={fileInput} className="visually-hidden" type="file" accept="application/pdf,.pdf" onChange={onFile} />
|
||
</header>
|
||
|
||
<div className="product-body">
|
||
<aside className="product-rail">
|
||
<div className="rail-progress"><strong>{project ? activeStage + 1 : 0}</strong><span>/ {STAGES.length}</span></div>
|
||
<nav>{STAGES.map((stage, index) => <div key={stage.label} className={index < activeStage ? "complete" : index === activeStage && project ? "active" : "pending"}><span>{index < activeStage ? <CheckIcon size={12} weight="bold" /> : index + 1}</span><p><strong>{stage.label}</strong><small>{stage.detail}</small></p></div>)}</nav>
|
||
{project ? <button className="change-plan" type="button" onClick={() => fileInput.current?.click()}><UploadSimpleIcon size={15} />更换图纸</button> : null}
|
||
</aside>
|
||
|
||
<div className="product-center">
|
||
{busy ? <div className="busy-banner"><CircleNotchIcon className="spin" size={16} /><span>{busy}</span></div> : null}
|
||
{error ? <div className="error-banner"><WarningCircleIcon size={17} /><span>{error}</span><button type="button" onClick={() => setError("")}>关闭</button></div> : null}
|
||
{renderWorkspace()}
|
||
</div>
|
||
|
||
<aside className="agent-panel">
|
||
<div className="agent-heading"><span><SparkleIcon size={15} weight="fill" /></span><div><strong>设计 Agent</strong><small>{readiness?.ready ? "模型与存储已就绪" : "等待系统配置"}</small></div></div>
|
||
<div className="agent-stream">
|
||
{!project ? <div className="agent-message"><p>上传户型图后,我会在每个关键决策点给出判断,也会主动追问缺失信息。</p></div> : null}
|
||
{project?.events.slice(-6).map((event) => <div key={event.id} className={`agent-message ${event.kind === "user" ? "user" : ""}`}><small>{event.kind === "user" ? "你" : event.kind === "assistant" ? "设计 Agent" : "工作记录"}</small><p>{event.message}</p></div>)}
|
||
{chatReply && !project?.events.some((event) => event.message === chatReply) ? <div className="agent-message"><p>{chatReply}</p></div> : null}
|
||
</div>
|
||
<form className="agent-input" onSubmit={chat}><textarea value={chatDraft} onChange={(event) => setChatDraft(event.target.value)} placeholder={project ? "告诉我你的偏好,或问我下一步怎么选" : "先上传图纸后开始对话"} disabled={!project || Boolean(busy)} /><button type="submit" disabled={!project || !chatDraft.trim() || Boolean(busy)} aria-label="发送"><PaperPlaneTiltIcon size={16} weight="fill" /></button></form>
|
||
</aside>
|
||
</div>
|
||
|
||
<SettingsCenter open={settingsOpen} onClose={() => setSettingsOpen(false)} onReadinessChange={setReadiness} />
|
||
</main>
|
||
);
|
||
}
|