334 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|