refactor(canvas): 优化模型选择器和定时同步配置

- 将模型选择器替换为统一的 shadcn 风格下拉组件
- 移除原生数字输入框的步进箭头样式
- 更新默认定时同步配置为每5分钟执行
- 修复画布助手和节点配置中的模型选择逻辑
- 统一各页面中的模型选择器样式和交互行为
This commit is contained in:
HouYunFei
2026-05-23 15:56:17 +08:00
parent c9682481d7
commit 593feddc3f
13 changed files with 303 additions and 213 deletions
+5 -5
View File
@@ -35,7 +35,7 @@ const emptySettings: AdminSettings = {
allowCustomChannel: true,
},
},
private: { channels: [], promptSync: { enabled: false, cron: "0 3 * * *" } },
private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" } },
};
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
@@ -367,8 +367,8 @@ export default function AdminSettingsPage() {
</Form.Item>
</Col>
<Col xs={24} md={16}>
<Form.Item name={["private", "promptSync", "cron"]} label="Cron 表达式" extra="默认每天 03:00 同步内置 GitHub 远程提示词源">
<Input placeholder="0 3 * * *" />
<Form.Item name={["private", "promptSync", "cron"]} label="Cron 表达式" extra="默认每 5 分钟同步内置 GitHub 远程提示词源">
<Input placeholder="*/5 * * * *" />
</Form.Item>
</Col>
</Row>
@@ -601,8 +601,8 @@ function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}
return {
channels: (setting.channels || []).map(normalizeChannel),
promptSync: {
enabled: setting.promptSync?.enabled === true,
cron: setting.promptSync?.cron || "0 3 * * *",
enabled: setting.promptSync?.enabled !== false,
cron: setting.promptSync?.cron || "*/5 * * * *",
},
};
}
@@ -1,11 +1,12 @@
"use client";
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 { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
import { Button, 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";
@@ -421,11 +422,11 @@ function AssistantComposer({
<AssistantModeSwitch mode={mode} theme={theme} onChange={onModeChange} />
{mode === "image" ? (
<>
<ComposerModelPill config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} />
<ModelPicker className="h-8 shrink-0" 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} />
</>
) : (
<ComposerModelPill config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
<ModelPicker className="h-8 shrink-0" config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
)}
</div>
<Button
@@ -443,45 +444,6 @@ function AssistantComposer({
);
}
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 (
<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()}
>
<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 }}>
@@ -105,7 +105,7 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
</div>
</div>
<div className="space-y-3">
<SettingTitle color={theme.node.muted}></SettingTitle>
<SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-4 gap-3">
{Array.from({ length: 10 }, (_, index) => index + 1).map((value) => (
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
@@ -165,7 +165,7 @@ function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix:
type="number"
min={1}
disabled={disabled}
className="min-w-0 flex-1 bg-transparent px-2 outline-none"
className="min-w-0 flex-1 bg-transparent px-2 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
value={value || ""}
onChange={(event) => onChange(Number(event.target.value) || null)}
onMouseDown={(event) => event.stopPropagation()}
@@ -181,7 +181,7 @@ function CountInput({ value, theme, onChange }: { value: number; theme: (typeof
type="number"
min={1}
max={15}
className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none"
className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
style={{ color: theme.node.text, WebkitTextFillColor: theme.node.text }}
value={value || ""}
onChange={(event) => onChange(Number(event.target.value) || null)}
+23 -90
View File
@@ -319,65 +319,46 @@
line-height: 1 !important;
}
.canvas-composer-tools {
container-type: inline-size;
}
.canvas-composer-icon.ant-btn,
.canvas-composer-model-pill {
.canvas-composer-icon.ant-btn {
flex: 0 0 32px;
width: 32px;
}
.canvas-composer-tools {
container-type: inline-size;
}
.canvas-composer-mode-button {
width: 32px;
}
.canvas-composer-model-text,
.canvas-composer-model-arrow,
.canvas-composer-mode-button span,
.canvas-model-picker-text,
.canvas-select-chevron,
.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;
.canvas-composer-model-picker {
width: 32px;
min-width: 32px;
justify-content: center;
padding-inline: 8px;
transition: opacity 0.15s ease;
gap: 0;
padding-inline: 0;
}
.canvas-composer-model-pill:hover {
opacity: 0.82;
}
@container (min-width: 400px) {
@container (min-width: 440px) {
.canvas-composer-mode-button {
padding-inline: 8px;
width: auto;
}
.canvas-composer-mode-button span,
.canvas-composer-model-text,
.canvas-composer-model-arrow {
.canvas-composer-mode-button span {
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;
@@ -388,69 +369,21 @@
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-model-picker {
width: auto;
min-width: 9rem;
justify-content: flex-start;
gap: 0.5rem;
padding-inline: 0.75rem;
}
.canvas-composer-icon.ant-btn {
flex: 0 1 auto;
max-width: 220px;
.canvas-model-picker-text,
.canvas-select-chevron {
display: inline;
}
}
.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;