Files
omniai-web/src/features/workbench/WorkbenchSelectChips.tsx
T
ludan 2b65206b84 feat: 电商克隆上传交互升级、视频模型选择器图标
【电商克隆 - 商品图上传交互重构】
- 新增上传预览大图区(clone-ai-upload-preview-wrap),点击缩略图可切换预览
- 选中缩略图增加 is-active 绿色边框高亮
- 预览区显示商品图编号 + 尺寸/比例/格式信息(formatProductImageSpec)
- 上传区到达 7 张上限时显示"已达上限"、阻止拖拽上传、输入框禁用
- 上传图片自动异步读取尺寸(width/height),无需等待上传完成即可展示
- 已上传素材区重构为列表头(标题+计数)+ 缩略图栈式布局
- 缩略图增加序号角标(1-7),删除按钮独立于缩略图下方
- selectedProductImageId 状态自动管理:删除/新增时自动切换到有效图片

【工作台 - 视频模型选择器图标】
- 新增 VIDEO_MODEL_ICON_URLS 映射(HappyHorse/Pixverse/Vidu/Wan/Kling)
- SelectChip 组件在 chipId=video-model 时显示模型品牌图标
- getVideoModelIconUrl 支持中英文模糊匹配

【样式】
- ecommerce.css: 预览区/素材栈/缩略图选中态/上限态完整样式
- dark-green.css: 主题层微调
2026-06-04 17:27:40 +08:00

289 lines
10 KiB
TypeScript

import { DownOutlined } from "@ant-design/icons";
import type { ReactNode } from "react";
import type { WorkbenchOption, WorkbenchFieldGroup } from "./workbenchConstants";
import { getRatioOptionClassName, getSettingsGridColumnsClassName } from "./workbenchReferenceUtils";
const VIDEO_MODEL_ICON_URLS = {
happyHorse: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/HappyHorse.svg",
pixverse: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/Pixverse.svg",
vidu: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/viduQ3.svg",
wanxiang: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/wan.svg",
kling: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/kling.svg",
} as const;
function getVideoModelIconUrl(option: WorkbenchOption): string | null {
const text = `${option.value} ${option.label}`.toLowerCase();
if (text.includes("happyhorse")) return VIDEO_MODEL_ICON_URLS.happyHorse;
if (text.includes("pixverse")) return VIDEO_MODEL_ICON_URLS.pixverse;
if (text.includes("vidu")) return VIDEO_MODEL_ICON_URLS.vidu;
if (text.includes("wan") || text.includes("万相")) return VIDEO_MODEL_ICON_URLS.wanxiang;
if (text.includes("kling") || text.includes("可灵")) return VIDEO_MODEL_ICON_URLS.kling;
return null;
}
export function SelectChip({
chipId,
value,
options,
disabled,
isOpen,
onToggle,
onClose,
onChange,
ariaLabel,
direction = "up",
}: {
chipId: string;
value: string;
options: WorkbenchOption[];
disabled?: boolean;
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
onChange: (value: string) => void;
ariaLabel?: string;
direction?: "up" | "down";
}) {
const activeOption = options.find((option) => option.value === value);
return (
<div
className={`ai-workbench-select-chip${chipId.endsWith("-model") ? " ai-workbench-select-chip--model" : ""}${disabled ? " is-disabled" : ""}${isOpen ? " is-open" : ""}`}
>
<button
type="button"
className="ai-workbench-select-chip__trigger"
onClick={onToggle}
disabled={disabled}
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={`${chipId}-listbox`}
>
<span className="ai-workbench-select-chip__copy">
<span className="ai-workbench-select-chip__value">{activeOption?.label || value}</span>
</span>
<DownOutlined className="ai-workbench-select-chip__arrow" />
</button>
{isOpen ? (
<div
id={`${chipId}-listbox`}
className={`ai-workbench-select-chip__dropdown ai-workbench-select-chip__dropdown--${direction} is-open`}
role="listbox"
>
{options.map((option, index) => {
const active = option.value === value;
const iconUrl = chipId === "video-model" ? getVideoModelIconUrl(option) : null;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={active}
className={`ai-workbench-select-chip__option${active ? " is-active" : ""}`}
style={{ transitionDelay: `${index * 18}ms` }}
onClick={() => {
onChange(option.value);
onClose();
}}
>
<span className="ai-workbench-select-chip__option-label">
<span className="ai-workbench-select-chip__option-dot" aria-hidden="true" />
{iconUrl ? (
<span className="ai-workbench-select-chip__option-icon" aria-hidden="true">
<img src={iconUrl} alt="" loading="lazy" />
</span>
) : null}
<span className="ai-workbench-select-chip__option-copy">
<span className="ai-workbench-select-chip__option-title">
<span>{option.label}</span>
{option.badge ? (
<span className="ai-workbench-select-chip__option-badge">{option.badge}</span>
) : null}
</span>
{option.description ? (
<span className="ai-workbench-select-chip__option-desc">{option.description}</span>
) : null}
</span>
</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
export function CompoundSelectChip({
chipId,
summary,
groups,
disabled,
isOpen,
onToggle,
direction = "up",
}: {
chipId: string;
summary: string;
groups: WorkbenchFieldGroup[];
disabled?: boolean;
isOpen: boolean;
onToggle: () => void;
direction?: "up" | "down";
}) {
return (
<div
className={`ai-workbench-select-chip ai-workbench-select-chip--compound${disabled ? " is-disabled" : ""}${isOpen ? " is-open" : ""}`}
>
<button
type="button"
className="ai-workbench-select-chip__trigger"
onClick={onToggle}
disabled={disabled}
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-controls={`${chipId}-panel`}
>
<span className="ai-workbench-select-chip__copy">
<span className="ai-workbench-select-chip__value">{summary}</span>
</span>
<DownOutlined className="ai-workbench-select-chip__arrow" />
</button>
{isOpen ? (
<div
id={`${chipId}-panel`}
className={`ai-workbench-select-chip__dropdown ai-workbench-select-chip__dropdown--compound ai-workbench-select-chip__dropdown--${direction} is-open`}
role="dialog"
>
<div className="ai-workbench-settings-panel">
{groups.map((group) => {
const currentLabel =
group.options.find((option) => option.value === group.value)?.label || group.value;
const fieldKey = `${group.kind || "pill"}-${group.label}`;
return (
<div
key={fieldKey}
className={`ai-workbench-settings-panel__field ai-workbench-settings-panel__field--${group.kind || "pill"}`}
>
<div className="ai-workbench-settings-panel__head">
<div className="ai-workbench-settings-panel__title-wrap">
{group.icon ? (
<span className="ai-workbench-settings-panel__title-icon">{group.icon}</span>
) : null}
<div className="ai-workbench-settings-panel__title-copy">
<div className="ai-workbench-settings-panel__title">{group.label}</div>
</div>
</div>
<span className="ai-workbench-settings-panel__current">{currentLabel}</span>
</div>
<fieldset
className={`ai-workbench-settings-panel__grid ai-workbench-settings-panel__grid--${group.kind || "pill"} ${getSettingsGridColumnsClassName(group.columns || 3)}`}
>
<legend className="ai-workbench-visually-hidden">{group.label}</legend>
{group.options.map((option) => {
const active = option.value === group.value;
return (
<button
key={`${fieldKey}-${option.value}`}
type="button"
aria-pressed={active}
className={`ai-workbench-settings-panel__option ai-workbench-settings-panel__option--${group.kind || "pill"}${active ? " is-active" : ""}`}
onClick={() => group.onChange(option.value)}
>
{group.kind === "ratio" ? (
<span className="ai-workbench-ratio-option">
<span
className={`ai-workbench-ratio-option__preview ${getRatioOptionClassName(option.value)}`}
>
<span className="ai-workbench-ratio-option__frame" />
</span>
<span className="ai-workbench-ratio-option__label">{option.label}</span>
</span>
) : (
<span>{option.label}</span>
)}
</button>
);
})}
</fieldset>
</div>
);
})}
</div>
</div>
) : null}
</div>
);
}
export function InlineOptionChip({
chipId,
value,
options,
icon,
disabled,
isOpen,
onToggle,
onClose,
onChange,
direction = "up",
}: {
chipId: string;
value: string;
options: WorkbenchOption[];
icon?: ReactNode;
disabled?: boolean;
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
onChange: (value: string) => void;
direction?: "up" | "down";
}) {
const activeOption = options.find((option) => option.value === value);
return (
<div className={`wb-inline-chip${isOpen ? " is-open" : ""}${disabled ? " is-disabled" : ""}`}>
<button
type="button"
className="wb-inline-chip__trigger"
onClick={onToggle}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={`${chipId}-listbox`}
>
{icon ? <span className="wb-inline-chip__icon">{icon}</span> : null}
<span>{activeOption?.label || value}</span>
</button>
{isOpen ? (
<div id={`${chipId}-listbox`} className={`wb-inline-chip__menu wb-inline-chip__menu--${direction}`} role="listbox">
{options.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={active}
className={`wb-inline-chip__option${active ? " is-active" : ""}`}
onClick={() => {
onChange(option.value);
onClose();
}}
>
<span>{option.label}</span>
{active ? <span className="wb-inline-chip__check"></span> : null}
</button>
);
})}
</div>
) : null}
</div>
);
}