517 lines
24 KiB
TypeScript
517 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import * as Tabs from "@radix-ui/react-tabs";
|
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
import {
|
|
ArrowRightIcon,
|
|
ArrowsOutIcon,
|
|
BuildingsIcon,
|
|
CheckIcon,
|
|
CircleNotchIcon,
|
|
CubeIcon,
|
|
EyeIcon,
|
|
EyeSlashIcon,
|
|
GearSixIcon,
|
|
ImageIcon,
|
|
LockSimpleIcon,
|
|
PaperPlaneTiltIcon,
|
|
PlusIcon,
|
|
SlidersHorizontalIcon,
|
|
SparkleIcon,
|
|
UploadSimpleIcon,
|
|
WarningCircleIcon,
|
|
} from "@phosphor-icons/react";
|
|
import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { SettingsCenter } from "@/components/settings-center";
|
|
import { styleDirections } from "@/lib/demo";
|
|
import type { ChatMessage, PlanLayer, ProjectSnapshot, WorkflowStage } from "@/types/workflow";
|
|
import type { SettingsReadiness } from "@/types/settings";
|
|
|
|
const iconWeight = "regular" as const;
|
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
|
|
const stageMeta: Array<Pick<WorkflowStage, "id" | "label" | "detail">> = [
|
|
{ id: "uploaded", label: "导入图纸", detail: "PDF 原文件" },
|
|
{ id: "region_selection", label: "选择户型", detail: "候选区域" },
|
|
{ id: "plan_review", label: "确认结构", detail: "墙、门窗与比例" },
|
|
{ id: "blockout", label: "生成白模", detail: "空间骨架" },
|
|
{ id: "style_brief", label: "明确风格", detail: "Style DNA" },
|
|
{ id: "direction_selection", label: "选择方向", detail: "三个方案" },
|
|
{ id: "render_review", label: "审阅效果", detail: "多视角" },
|
|
{ id: "editing", label: "多轮修改", detail: "局部编辑" },
|
|
];
|
|
|
|
export function WorkflowStudio() {
|
|
const [project, setProject] = useState<ProjectSnapshot | null>(null);
|
|
const [layers, setLayers] = useState<PlanLayer[]>([]);
|
|
const [selectedDirection, setSelectedDirection] = useState("quiet-modern");
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [draft, setDraft] = useState("");
|
|
const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference");
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [readiness, setReadiness] = useState<SettingsReadiness | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [workflowError, setWorkflowError] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const currentDirection = useMemo(
|
|
() => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0],
|
|
[selectedDirection],
|
|
);
|
|
|
|
const workflowStages = useMemo<WorkflowStage[]>(() => {
|
|
const activeIndex = project ? stageMeta.findIndex((item) => item.id === project.stage) : -1;
|
|
return stageMeta.map((stage, index) => ({
|
|
...stage,
|
|
status: activeIndex < 0 ? "pending" : index < activeIndex ? "complete" : index === activeIndex ? "active" : "pending",
|
|
}));
|
|
}, [project]);
|
|
|
|
const selectedRegion = useMemo(
|
|
() => project?.plan.regions.find((region) => region.id === project.plan.selected_region_id) ?? null,
|
|
[project],
|
|
);
|
|
|
|
function applyProject(next: ProjectSnapshot) {
|
|
setProject(next);
|
|
setLayers(next.plan.layers.map((layer) => ({ ...layer, count: layer.element_count ?? undefined })));
|
|
setMessages([
|
|
...next.plan.ingestion_notes.map((body, index) => ({
|
|
id: `ingestion-${index}`,
|
|
role: "assistant" as const,
|
|
body,
|
|
meta: "图纸解析",
|
|
})),
|
|
...next.plan.issues.filter((issue) => !issue.resolved).map((issue) => ({
|
|
id: `issue-${issue.id}`,
|
|
role: "assistant" as const,
|
|
body: issue.message,
|
|
meta: "等待确认",
|
|
})),
|
|
]);
|
|
}
|
|
|
|
useEffect(() => {
|
|
async function loadReadiness() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" });
|
|
if (!response.ok) return;
|
|
const current = await response.json() as SettingsReadiness;
|
|
setReadiness(current);
|
|
if (!current.ready && !window.sessionStorage.getItem("settings-intro-seen")) {
|
|
window.sessionStorage.setItem("settings-intro-seen", "true");
|
|
setSettingsOpen(true);
|
|
}
|
|
} catch {
|
|
setReadiness(null);
|
|
}
|
|
}
|
|
void loadReadiness();
|
|
fetch(`${API_BASE}/v1/projects`, { cache: "no-store" })
|
|
.then((response) => response.ok ? response.json() as Promise<ProjectSnapshot[]> : [])
|
|
.then((projects) => {
|
|
const latest = projects.find((item) => item.project_id !== "demo-apartment");
|
|
if (latest) applyProject(latest);
|
|
})
|
|
.catch(() => undefined);
|
|
}, []);
|
|
|
|
async function readApiError(response: Response, fallback: string) {
|
|
try {
|
|
const payload = await response.json() as { detail?: string | { message?: string } };
|
|
if (typeof payload.detail === "string") return payload.detail;
|
|
if (payload.detail?.message) return payload.detail.message;
|
|
} catch {
|
|
// Keep the readable fallback.
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
async function uploadPlan(file: File) {
|
|
if (!readiness?.ready) {
|
|
setSettingsOpen(true);
|
|
setWorkflowError("请先完成系统设置和真实生图验证。");
|
|
return;
|
|
}
|
|
setUploading(true);
|
|
setWorkflowError(null);
|
|
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 readApiError(response, "图纸上传或解析失败。"));
|
|
applyProject(await response.json() as ProjectSnapshot);
|
|
} catch (error) {
|
|
setWorkflowError(error instanceof Error ? error.message : "图纸上传或解析失败。");
|
|
} finally {
|
|
setUploading(false);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
}
|
|
}
|
|
|
|
function chooseFile(event: ChangeEvent<HTMLInputElement>) {
|
|
const file = event.target.files?.[0];
|
|
if (file) void uploadPlan(file);
|
|
}
|
|
|
|
async function selectRegion(regionId: string) {
|
|
if (!project || project.stage !== "region_selection") return;
|
|
setWorkflowError(null);
|
|
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/commands`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
command: "select_region",
|
|
expected_revision: project.revision,
|
|
payload: { region_id: regionId },
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
setWorkflowError(await readApiError(response, "候选户型选择失败。"));
|
|
return;
|
|
}
|
|
applyProject(await response.json() as ProjectSnapshot);
|
|
}
|
|
|
|
function toggleLayer(id: string) {
|
|
setLayers((items) =>
|
|
items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)),
|
|
);
|
|
}
|
|
|
|
function sendMessage(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
const value = draft.trim();
|
|
if (!value) return;
|
|
setMessages((items) => [
|
|
...items,
|
|
{ id: `user-${Date.now()}`, role: "user", body: value },
|
|
{
|
|
id: `assistant-${Date.now()}`,
|
|
role: "assistant",
|
|
body: "已记录为设计约束。正式接入后,这条指令会先更新布局锁定和 Style DNA,再只重绘受影响视角。",
|
|
meta: "结构化修改",
|
|
},
|
|
]);
|
|
setDraft("");
|
|
}
|
|
|
|
return (
|
|
<Tooltip.Provider delayDuration={250}>
|
|
<main className="studio-shell">
|
|
<header className="topbar">
|
|
<div className="brand-block">
|
|
<div className="brand-mark" aria-hidden="true">
|
|
<BuildingsIcon size={19} weight="fill" />
|
|
</div>
|
|
<div>
|
|
<p className="brand-name">空间风格工作台</p>
|
|
<p className="brand-subtitle">{project?.name ?? "新建住宅概念方案"}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="topbar-actions">
|
|
<span className="save-state"><CheckIcon size={14} weight="bold" /> 已保存</span>
|
|
<button className="button button-secondary settings-button" type="button" onClick={() => setSettingsOpen(true)}>
|
|
<GearSixIcon size={16} /> 系统设置
|
|
{readiness && !readiness.ready ? <span className="settings-alert-dot" aria-label="有必备配置未完成" /> : null}
|
|
</button>
|
|
<button className="button button-secondary" type="button">版本记录</button>
|
|
<button
|
|
className="button button-primary"
|
|
type="button"
|
|
disabled={uploading || Boolean(project)}
|
|
onClick={() => readiness?.ready ? fileInputRef.current?.click() : setSettingsOpen(true)}
|
|
title={!readiness?.ready ? "请先完成系统设置" : project ? "当前阶段请在图纸上完成候选区域确认" : undefined}
|
|
>
|
|
{uploading ? "正在解析" : project ? "按图确认" : readiness?.ready ? "导入图纸" : "配置后开始"} <ArrowRightIcon size={16} weight="bold" />
|
|
</button>
|
|
<input ref={fileInputRef} className="visually-hidden" type="file" accept="application/pdf,.pdf" onChange={chooseFile} />
|
|
</div>
|
|
</header>
|
|
|
|
<div className="studio-grid">
|
|
<aside className="workflow-rail" aria-label="设计流程">
|
|
<div className="rail-heading">
|
|
<p>设计流程</p>
|
|
<span>{project ? Math.max(1, stageMeta.findIndex((item) => item.id === project.stage) + 1) : 0} / 8</span>
|
|
</div>
|
|
<nav className="stage-list">
|
|
{workflowStages.map((stage) => (
|
|
<button
|
|
className={`stage-item stage-${stage.status}`}
|
|
key={stage.id}
|
|
type="button"
|
|
aria-current={stage.status === "active" ? "step" : undefined}
|
|
>
|
|
<span className="stage-index" aria-hidden="true">
|
|
{stage.status === "complete" ? <CheckIcon size={13} weight="bold" /> : null}
|
|
</span>
|
|
<span className="stage-copy">
|
|
<strong>{stage.label}</strong>
|
|
<small>{stage.detail}</small>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="rail-note">
|
|
<WarningCircleIcon size={18} weight={iconWeight} />
|
|
<p>{project
|
|
? project.plan.issues.filter((issue) => !issue.resolved).length > 0
|
|
? `还有 ${project.plan.issues.filter((issue) => !issue.resolved).length} 项图纸问题需要确认。`
|
|
: "当前阶段的信息已确认。"
|
|
: "请先导入一份 PDF 户型图。"}</p>
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="workspace">
|
|
<Tabs.Root defaultValue="plan" className="workspace-tabs">
|
|
<div className="workspace-toolbar">
|
|
<Tabs.List className="tab-list" aria-label="工作区视图">
|
|
<Tabs.Trigger className="tab-trigger" value="plan">
|
|
<SlidersHorizontalIcon size={16} /> 户型清洗
|
|
</Tabs.Trigger>
|
|
<Tabs.Trigger className="tab-trigger" value="blockout">
|
|
<CubeIcon size={16} /> 3D 白模
|
|
</Tabs.Trigger>
|
|
<Tabs.Trigger className="tab-trigger" value="renders">
|
|
<ImageIcon size={16} /> 效果图
|
|
</Tabs.Trigger>
|
|
</Tabs.List>
|
|
|
|
<div className="canvas-actions">
|
|
<Tooltip.Root>
|
|
<Tooltip.Trigger asChild>
|
|
<button className="icon-button" type="button" aria-label="适应画布">
|
|
<ArrowsOutIcon size={18} />
|
|
</button>
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Portal><Tooltip.Content className="tooltip">适应画布</Tooltip.Content></Tooltip.Portal>
|
|
</Tooltip.Root>
|
|
<button className="button button-secondary compact" type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
|
<UploadSimpleIcon size={16} /> {project ? "更换图纸" : "导入图纸"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs.Content value="plan" className="tab-content">
|
|
{!project ? (
|
|
<div className="plan-upload-empty">
|
|
<div className="upload-emblem"><UploadSimpleIcon size={30} /></div>
|
|
<h2>{uploading ? "正在读取和分析图纸" : "导入第一份真实户型图"}</h2>
|
|
<p>当前支持 25 MB 以内的 PDF。系统会保留原文件、判断矢量或扫描类型,并检测同页中的多个候选平面区域。</p>
|
|
<button className="button button-primary" type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading || !readiness?.ready}>
|
|
{uploading ? <CircleNotchIcon className="spin" size={16} /> : <UploadSimpleIcon size={16} />}
|
|
{uploading ? "解析中,请稍候" : readiness?.ready ? "选择 PDF 图纸" : "完成设置后导入"}
|
|
</button>
|
|
{workflowError ? <p className="workflow-error"><WarningCircleIcon size={16} /> {workflowError}</p> : null}
|
|
</div>
|
|
) : (
|
|
<div className="plan-workspace">
|
|
<div className="plan-canvas">
|
|
<div className="canvas-labels">
|
|
<span>{selectedRegion?.name ?? `${project.plan.regions.length} 个候选平面区域`}</span>
|
|
<span>{project.plan.vector_based ? "矢量 PDF" : "扫描 PDF"} · {project.plan.vector_element_count.toLocaleString()} 个矢量元素</span>
|
|
</div>
|
|
<div className="plan-document-frame">
|
|
{/* eslint-disable-next-line @next/next/no-img-element -- authenticated runtime preview URL. */}
|
|
<img
|
|
src={`${API_BASE}/v1/projects/${project.project_id}/preview`}
|
|
alt={`${project.name} 的 PDF 首页预览`}
|
|
className="plan-document-image"
|
|
/>
|
|
<div className="region-layer" aria-label="候选户型区域">
|
|
{project.plan.regions.map((region, index) => {
|
|
const [x0, y0, x1, y1] = region.bounds;
|
|
const selected = region.id === project.plan.selected_region_id;
|
|
return (
|
|
<button
|
|
className={`detected-region ${selected ? "selected" : ""} ${region.recommended ? "recommended" : ""}`}
|
|
key={region.id}
|
|
type="button"
|
|
style={{ left: `${x0 * 100}%`, top: `${y0 * 100}%`, width: `${(x1 - x0) * 100}%`, height: `${(y1 - y0) * 100}%` }}
|
|
onClick={() => void selectRegion(region.id)}
|
|
disabled={project.stage !== "region_selection"}
|
|
aria-label={`选择${region.name}`}
|
|
>
|
|
<span>{index + 1}</span>
|
|
<strong>{region.name}</strong>
|
|
<small>{region.recommended ? "建议选择 · " : ""}{Math.round(region.confidence * 100)}%</small>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="canvas-status">
|
|
<span><CheckIcon size={14} weight="bold" /> 原图与解析预览已保存至 MinIO</span>
|
|
<span>{project.stage === "region_selection" ? "请选择目标户型" : "目标区域已确认"}</span>
|
|
</div>
|
|
{workflowError ? <p className="workflow-error"><WarningCircleIcon size={16} /> {workflowError}</p> : null}
|
|
</div>
|
|
|
|
<aside className="layer-panel">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<p className="panel-title">解析内容</p>
|
|
<p className="panel-caption">PDF 首页 · {project.plan.page_count} 页文件</p>
|
|
</div>
|
|
</div>
|
|
<div className="layer-list">
|
|
{layers.map((layer) => (
|
|
<button
|
|
className={`layer-row ${layer.visible ? "is-visible" : ""}`}
|
|
key={layer.id}
|
|
type="button"
|
|
onClick={() => toggleLayer(layer.id)}
|
|
>
|
|
{layer.visible ? <EyeIcon size={17} /> : <EyeSlashIcon size={17} />}
|
|
<span>{layer.label}</span>
|
|
{layer.count ? <small>{layer.count.toLocaleString()}</small> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="panel-divider" />
|
|
<p className="panel-title">识别状态</p>
|
|
<div className="ingestion-facts">
|
|
<span><CheckIcon size={13} /> {project.plan.vector_based ? "矢量信息可用" : "需要视觉分割"}</span>
|
|
<span><CheckIcon size={13} /> {project.plan.ocr_used ? "百度 OCR 已参与" : "PDF 文字层可读取"}</span>
|
|
<span><WarningCircleIcon size={13} /> 比例尚待交叉确认</span>
|
|
</div>
|
|
|
|
<div className="panel-divider" />
|
|
<p className="panel-title">原家具处理</p>
|
|
<div className="segmented-control">
|
|
<button type="button" className={furnitureMode === "reference" ? "active" : ""} onClick={() => setFurnitureMode("reference")}>保留参考</button>
|
|
<button type="button" className={furnitureMode === "remove" ? "active" : ""} onClick={() => setFurnitureMode("remove")}>仅看结构</button>
|
|
</div>
|
|
<p className="helper-text">候选区域确认后,下一阶段会分离墙、门窗、文字与家具意图。</p>
|
|
</aside>
|
|
</div>
|
|
)}
|
|
</Tabs.Content>
|
|
|
|
<Tabs.Content value="blockout" className="tab-content empty-workspace">
|
|
<CubeIcon size={38} weight="thin" />
|
|
<h2>白模将在结构确认后生成</h2>
|
|
<p>系统会拉伸墙体、门窗和柱,并自动建立三个室内相机。</p>
|
|
</Tabs.Content>
|
|
|
|
<Tabs.Content value="renders" className="tab-content empty-workspace">
|
|
<ImageIcon size={38} weight="thin" />
|
|
<h2>先选定风格方向</h2>
|
|
<p>低成本方向图通过后,再生成完整多视角效果图。</p>
|
|
</Tabs.Content>
|
|
</Tabs.Root>
|
|
|
|
<section className="conversation" aria-label="设计对话">
|
|
<div className="message-stream">
|
|
{!project ? (
|
|
<article className="message message-assistant">
|
|
<span className="assistant-icon"><SparkleIcon size={15} weight="fill" /></span>
|
|
<div><small>等待图纸</small><p>导入 PDF 后,我会先判断图纸类型并请你选择目标户型区域。</p></div>
|
|
</article>
|
|
) : null}
|
|
{messages.slice(-3).map((message) => (
|
|
<article className={`message message-${message.role}`} key={message.id}>
|
|
{message.role === "assistant" ? (
|
|
<span className="assistant-icon"><SparkleIcon size={15} weight="fill" /></span>
|
|
) : null}
|
|
<div>
|
|
{message.meta ? <small>{message.meta}</small> : null}
|
|
<p>{message.body}</p>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
<form className="composer" onSubmit={sendMessage}>
|
|
<label htmlFor="design-instruction">告诉我需要保留什么,或者希望空间给人怎样的感受</label>
|
|
<div className="composer-row">
|
|
<input
|
|
id="design-instruction"
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
placeholder={project ? "例如:保留床和窗的位置,客厅更松弛,不要冷灰" : "导入图纸后开始对话"}
|
|
disabled={!project}
|
|
/>
|
|
<button className="send-button" type="submit" aria-label="发送设计指令" disabled={!project}>
|
|
<PaperPlaneTiltIcon size={18} weight="fill" />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
</section>
|
|
|
|
<aside className="style-inspector">
|
|
<div className="inspector-heading">
|
|
<div>
|
|
<p className="panel-title">Style DNA</p>
|
|
<p className="panel-caption">跨视角设计约束</p>
|
|
</div>
|
|
<span className="lock-label"><LockSimpleIcon size={13} /> 2 项锁定</span>
|
|
</div>
|
|
|
|
<div className="style-section">
|
|
<label htmlFor="style-concept">设计概念</label>
|
|
<textarea key={project?.project_id ?? "empty"} id="style-concept" defaultValue={project?.style.concept ?? "导入图纸后建立 Style DNA"} disabled={!project} />
|
|
</div>
|
|
|
|
<div className="style-section">
|
|
<p className="style-label">方向草案</p>
|
|
<div className="direction-list">
|
|
{styleDirections.map((direction) => (
|
|
<button
|
|
type="button"
|
|
key={direction.id}
|
|
className={`direction-option ${selectedDirection === direction.id ? "selected" : ""}`}
|
|
onClick={() => setSelectedDirection(direction.id)}
|
|
>
|
|
<span className="direction-colors" aria-hidden="true">
|
|
{direction.colors.map((color) => (
|
|
<span key={color} style={{ backgroundColor: color }} />
|
|
))}
|
|
</span>
|
|
<span>
|
|
<strong>{direction.name}</strong>
|
|
<small>{direction.summary}</small>
|
|
</span>
|
|
{selectedDirection === direction.id ? <CheckIcon size={15} weight="bold" /> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="style-section material-section">
|
|
<p className="style-label">材料语言</p>
|
|
<div className="tag-list">
|
|
{currentDirection.materials.map((material) => <span key={material}>{material}</span>)}
|
|
<button type="button"><PlusIcon size={13} /> 添加</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="style-section constraint-section">
|
|
<p className="style-label">明确避免</p>
|
|
<p>大面积冷灰、高亮岩板、过量灯带</p>
|
|
</div>
|
|
|
|
<div className="inspector-footer">
|
|
<div>
|
|
<CircleNotchIcon size={17} />
|
|
<span>等待结构确认</span>
|
|
</div>
|
|
<button className="button button-primary full" type="button">保存风格草案</button>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
<SettingsCenter
|
|
open={settingsOpen}
|
|
onClose={() => setSettingsOpen(false)}
|
|
onReadinessChange={setReadiness}
|
|
/>
|
|
</main>
|
|
</Tooltip.Provider>
|
|
);
|
|
}
|