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

334 lines
14 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,
ImageIcon,
LockSimpleIcon,
PaperPlaneTiltIcon,
PlusIcon,
SlidersHorizontalIcon,
SparkleIcon,
UploadSimpleIcon,
WarningCircleIcon,
} from "@phosphor-icons/react";
import Image from "next/image";
import { FormEvent, useMemo, useState } from "react";
import {
initialLayers,
initialMessages,
styleDirections,
workflowStages,
} from "@/lib/demo";
import type { ChatMessage } from "@/types/workflow";
const iconWeight = "regular" as const;
export function WorkflowStudio() {
const [layers, setLayers] = useState(initialLayers);
const [selectedDirection, setSelectedDirection] = useState("quiet-modern");
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [draft, setDraft] = useState("");
const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference");
const currentDirection = useMemo(
() => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0],
[selectedDirection],
);
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">11-2-104 住宅概念方案</p>
</div>
</div>
<div className="topbar-actions">
<span className="save-state"><CheckIcon size={14} weight="bold" /> 已保存</span>
<button className="button button-secondary" type="button">版本记录</button>
<button className="button button-primary" type="button">
确认结构 <ArrowRightIcon size={16} weight="bold" />
</button>
</div>
</header>
<div className="studio-grid">
<aside className="workflow-rail" aria-label="设计流程">
<div className="rail-heading">
<p>设计流程</p>
<span>3 / 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> 2 项需要确认,确认后才能生成空间白模。</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">
<UploadSimpleIcon size={16} /> 更换图纸
</button>
</div>
</div>
<Tabs.Content value="plan" className="tab-content">
<div className="plan-workspace">
<div className="plan-canvas">
<div className="canvas-labels">
<span>上方住宅户型</span>
<span>矢量 PDF · 43 CAD 图层</span>
</div>
<div className="plan-image-frame">
<Image
src="/sample-plan.png"
alt="从真实 PDF 中分离出的住宅建筑结构平面图"
fill
priority
sizes="(max-width: 900px) 100vw, 60vw"
className="plan-image"
/>
<div className="region-outline" aria-hidden="true" />
<button className="issue-marker issue-one" type="button" aria-label="比例待确认">1</button>
<button className="issue-marker issue-two" type="button" aria-label="家具意图待确认">2</button>
</div>
<div className="canvas-status">
<span><CheckIcon size={14} weight="bold" /> 已分离建筑结构</span>
<span>比例待交叉校准</span>
</div>
</div>
<aside className="layer-panel">
<div className="panel-title-row">
<div>
<p className="panel-title">图层</p>
<p className="panel-caption">保留矢量信息</p>
</div>
<button className="icon-button small" type="button" aria-label="添加图层">
<PlusIcon size={16} />
</button>
</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}</small> : null}
</button>
))}
</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">
{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="例如:保留床和窗的位置,客厅更松弛,不要冷灰"
/>
<button className="send-button" type="submit" aria-label="发送设计指令">
<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 id="style-concept" defaultValue="温暖、克制、带自然材质感的现代住宅" />
</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>
</main>
</Tooltip.Provider>
);
}