Files
zhuangxiu/apps/web/components/workflow-studio.tsx
T

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>
);
}