refactor(canvas): 重构画布助手面板组件结构

- 移除 Segmented 组件并替换为自定义模式切换按钮
- 替换 ModelPicker 为 ComposerModelPill 组件
- 添加下拉菜单实现模型选择功能
- 优化 CSS 样式类和响应式布局
- 更新图标引入和工具栏组件结构
- 简化提示词库按钮样式配置
This commit is contained in:
HouYunFei
2026-05-23 15:08:13 +08:00
parent e319481976
commit ca4e87bddf
4 changed files with 204 additions and 60 deletions
@@ -1,12 +1,11 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
import { Button, ConfigProvider, Modal, Segmented, Tooltip } from "antd";
import { useEffect, useMemo, useState } from "react";
import { ArrowUp, ChevronDown, Cpu, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
import { Button, Dropdown, Modal, Tooltip } from "antd";
import { motion } from "motion/react";
import { ImageGenerationPending } from "@/components/image-generation-pending";
import { ModelPicker } from "@/components/model-picker";
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { nanoid } from "nanoid";
@@ -417,41 +416,16 @@ function AssistantComposer({
placeholder={mode === "image" ? "描述你想生成或修改的图片" : "输入你想问的问题"}
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<div className="canvas-composer-tools flex min-w-0 flex-1 items-center gap-1">
<CanvasPromptLibrary onSelect={onPromptChange} />
<CanvasThemeProvider theme={theme}>
<Segmented
size="small"
className="canvas-composer-mode !rounded-full !p-0.5"
value={mode}
onChange={(value) => onModeChange(value as AssistantMode)}
options={[
{
value: "ask",
label: (
<Tooltip title="文本">
<MessageSquare className="size-4" />
</Tooltip>
),
},
{
value: "image",
label: (
<Tooltip title="生图">
<ImageIcon className="size-4" />
</Tooltip>
),
},
]}
/>
</CanvasThemeProvider>
<AssistantModeSwitch mode={mode} theme={theme} onChange={onModeChange} />
{mode === "image" ? (
<>
<ModelPicker config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} />
<CanvasImageSettingsPopover config={config} placement="topRight" getPopupContainer={() => document.body} onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
<ComposerModelPill config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} />
<CanvasImageSettingsPopover config={config} placement="topRight" getPopupContainer={() => document.body} buttonClassName="canvas-composer-settings canvas-composer-icon !h-8 !min-w-8 !rounded-full !px-2" onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
</>
) : (
<ModelPicker config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
<ComposerModelPill config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
)}
</div>
<Button
@@ -469,21 +443,66 @@ function AssistantComposer({
);
}
function CanvasThemeProvider({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) {
function ComposerModelPill({ config, value, onChange, onMissingConfig }: { config: AiConfig; value: string; onChange: (model: string) => void; onMissingConfig: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const options = Array.from(new Set([value, ...config.models].filter(Boolean)));
return (
<ConfigProvider
theme={{
token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel },
components: {
Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text },
Input: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke },
InputNumber: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke },
Segmented: { itemColor: theme.node.text, itemHoverBg: theme.node.fill, itemSelectedBg: theme.node.activeStroke, itemSelectedColor: theme.node.panel, trackBg: theme.toolbar.panel, trackPadding: 2 },
},
<Dropdown
trigger={["click"]}
overlayClassName="canvas-model-dropdown"
menu={{
items: options.map((model) => ({ key: model, label: <ModelMenuLabel model={model} /> })),
onClick: ({ key }) => onChange(String(key)),
selectable: true,
selectedKeys: value ? [value] : [],
}}
onOpenChange={(open) => open && !options.length && onMissingConfig()}
>
{children}
</ConfigProvider>
<button type="button" className="canvas-composer-model-pill" style={{ background: theme.node.fill, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
<ModelIcon model={value} />
<span className="canvas-composer-model-text truncate">{value || "模型"}</span>
<ChevronDown className="canvas-composer-model-arrow size-3.5 opacity-55" />
</button>
</Dropdown>
);
}
function ModelMenuLabel({ model }: { model: string }) {
return (
<span className="flex min-w-0 items-center gap-2">
<ModelIcon model={model} />
<span className="truncate">{model}</span>
</span>
);
}
function ModelIcon({ model }: { model: string }) {
const name = model.toLowerCase();
const icon = name.includes("claude") || name.includes("anthropic") ? "/icons/claude.svg" : name.includes("gemini") || name.includes("google") ? "/icons/gemini.svg" : name.includes("gpt") || name.includes("openai") ? "/icons/openai.svg" : "";
return icon ? <img src={icon} alt="" className="size-4 shrink-0" /> : <Cpu className="size-4 shrink-0 opacity-70" />;
}
function AssistantModeSwitch({ mode, theme, onChange }: { mode: AssistantMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (mode: AssistantMode) => void }) {
return (
<div className="canvas-composer-mode-switch flex h-8 shrink-0 items-center rounded-full p-0.5" style={{ background: theme.node.fill }}>
{[
{ value: "ask" as const, title: "对话", icon: <MessageSquare className="size-4" /> },
{ value: "image" as const, title: "生图", icon: <ImageIcon className="size-4" /> },
].map((item) => (
<Tooltip key={item.value} title={item.title}>
<button
type="button"
className="canvas-composer-mode-button flex h-7 cursor-pointer items-center justify-center gap-1 rounded-full border-0 bg-transparent transition"
style={{ background: mode === item.value ? theme.node.activeStroke : "transparent", color: mode === item.value ? theme.node.panel : theme.node.text }}
onClick={() => onChange(item.value)}
aria-label={item.title}
>
{item.icon}
<span>{item.title}</span>
</button>
</Tooltip>
))}
</div>
);
}
@@ -16,9 +16,9 @@ export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) =
<>
<Tooltip title="提示词库">
<Button
shape="circle"
className="!h-8 !w-8 !min-w-8 shrink-0 !bg-transparent"
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
type="text"
className="!h-8 !w-8 !min-w-8 shrink-0 !rounded-full !bg-transparent !p-0"
style={{ color: theme.node.text }}
icon={<BookOpen className="size-3.5" />}
onClick={() => setOpen(true)}
aria-label="提示词库"
+132 -8
View File
@@ -319,6 +319,138 @@
line-height: 1 !important;
}
.canvas-composer-tools {
container-type: inline-size;
}
.canvas-composer-icon.ant-btn,
.canvas-composer-model-pill {
flex: 0 0 32px;
width: 32px;
}
.canvas-composer-mode-button {
width: 32px;
}
.canvas-composer-model-text,
.canvas-composer-model-arrow,
.canvas-composer-mode-button span,
.canvas-composer-icon.ant-btn > span:not(.ant-btn-icon) {
display: none;
}
.canvas-composer-model-pill {
align-items: center;
border: 0;
border-radius: 999px;
cursor: pointer;
display: flex;
gap: 7px;
height: 32px;
justify-content: center;
padding-inline: 8px;
transition: opacity 0.15s ease;
}
.canvas-composer-model-pill:hover {
opacity: 0.82;
}
@container (min-width: 400px) {
.canvas-composer-mode-button {
padding-inline: 8px;
width: auto;
}
.canvas-composer-mode-button span,
.canvas-composer-model-text,
.canvas-composer-model-arrow {
display: inline;
font-size: 12px;
line-height: 1;
}
.canvas-composer-model-pill {
flex-basis: 180px;
max-width: 180px;
width: 180px !important;
justify-content: flex-start;
}
.canvas-composer-icon.ant-btn {
flex: 0 1 auto;
max-width: 170px;
width: auto !important;
}
.canvas-composer-icon.ant-btn > span:not(.ant-btn-icon) {
display: inline-flex;
min-width: 0;
}
}
@container (min-width: 560px) {
.canvas-composer-model-pill {
flex-basis: 240px;
max-width: 240px;
width: 240px !important;
}
.canvas-composer-icon.ant-btn {
flex: 0 1 auto;
max-width: 220px;
}
}
.canvas-model-dropdown .ant-dropdown-menu {
border: 1px solid rgba(120, 113, 108, 0.18);
border-radius: 12px;
box-shadow: 0 18px 42px rgba(28, 25, 23, 0.18);
min-width: 220px;
padding: 6px;
}
.canvas-model-dropdown .ant-dropdown-menu-item {
border-radius: 8px;
color: #292524;
font-size: 13px;
line-height: 1;
margin: 1px 0;
min-height: 32px;
padding: 7px 10px !important;
}
.canvas-model-dropdown .ant-dropdown-menu-item:hover {
background: rgba(28, 25, 23, 0.06) !important;
}
.canvas-model-dropdown .ant-dropdown-menu-item-selected,
.canvas-model-dropdown .ant-dropdown-menu-item-selected:hover {
background: rgba(28, 25, 23, 0.1) !important;
color: #1c1917 !important;
font-weight: 500;
}
.dark .canvas-model-dropdown .ant-dropdown-menu {
background: #1f1d1a;
border-color: rgba(214, 211, 209, 0.14);
}
.dark .canvas-model-dropdown .ant-dropdown-menu-item {
color: #f5f5f4;
}
.dark .canvas-model-dropdown .ant-dropdown-menu-item:hover {
background: rgba(245, 245, 244, 0.08) !important;
}
.dark .canvas-model-dropdown .ant-dropdown-menu-item-selected,
.dark .canvas-model-dropdown .ant-dropdown-menu-item-selected:hover {
background: rgba(245, 245, 244, 0.14) !important;
color: #fafaf9 !important;
}
.canvas-control-select.ant-select-focused .ant-select-selector,
.canvas-control-number.ant-input-number-focused {
border-color: #a8a29e !important;
@@ -334,14 +466,6 @@
border-color: #78716c !important;
}
.canvas-composer-mode.ant-segmented .ant-segmented-item-label {
align-items: center;
display: flex;
height: 30px;
justify-content: center;
padding-inline: 9px;
}
.canvas-image-settings-popover .ant-popover-inner {
border-radius: 18px;
box-shadow: 0 18px 54px rgba(28, 25, 23, 0.16);
+4 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { Select } from "antd";
import { Cpu } from "lucide-react";
import styles from "./model-picker.module.css";
import type { AiConfig } from "@/stores/use-config-store";
@@ -53,9 +54,9 @@ export function ModelPicker({ config, value, onChange, className, fullWidth = fa
function ModelLabel({ model }: { model: string }) {
const icon = resolveModelIcon(model);
return (
<span className="flex min-w-0 items-center gap-2">
{icon && <img src={icon} alt="" className="size-4 shrink-0" />}
<span className="truncate">{model}</span>
<span className="model-picker-label flex min-w-0 items-center gap-2">
{icon ? <img src={icon} alt="" className="size-4 shrink-0" /> : <Cpu className="size-4 shrink-0 opacity-70" />}
<span className="model-picker-label-text truncate">{model}</span>
</span>
);
}