feat(canvas): 模型选择 - 支持彩色展示标签并优化目录与积分交互

This commit is contained in:
ddCat
2026-09-21 11:11:10 +08:00
parent d5ff22258e
commit b8eefdad70
31 changed files with 410 additions and 63 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
import { Check, ChevronDown, ChevronLeft, Coins } from "lucide-react";
import { Check, ChevronDown, Coins } from "lucide-react";
import { Popover } from "antd";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
@@ -12,6 +12,7 @@ import { modelDisplayName, modelIcon, modelOptionName, resolveModelChannel, sele
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { ModelLogo } from "@/components/model-logo";
import { ModelTags } from "@/components/model-tags";
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
type ModelPickerProps = {
@@ -54,7 +55,6 @@ export function ModelPicker({
const theme = (canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark) as CanvasTheme;
const [open, setOpen] = useState(false);
const [activeGroupKey, setActiveGroupKey] = useState<string | null>(null);
const [previewedModel, setPreviewedModel] = useState("");
const [triggerWidth, setTriggerWidth] = useState<number | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
@@ -121,8 +121,7 @@ export function ModelPicker({
if (nextOpen && !options.length) onMissingConfig?.();
if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId }));
if (nextOpen) {
setPreviewedModel(current || options[0] || "");
setActiveGroupKey(null);
setActiveGroupKey(optionGroups.find((group) => group.models.some((item) => item.models.includes(current)))?.key ?? null);
}
setOpen(nextOpen);
};
@@ -186,8 +185,7 @@ export function ModelPicker({
<div className="canvas-model-picker-brands" aria-label="选择产品模型">
{optionGroups.map((group) => {
const groupCurrent = group.models.find((item) => item.models.includes(current));
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
return <button key={group.key} type="button" data-model-picker-item className="canvas-model-picker-brand" onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); focusMenuOption(); }}>
return <button key={group.key} type="button" data-model-picker-item className={cn("canvas-model-picker-brand", groupCurrent && "is-active")} aria-pressed={Boolean(groupCurrent)} onClick={() => { setActiveGroupKey(group.key); focusMenuOption(); }}>
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</small></span>
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
@@ -197,9 +195,7 @@ export function ModelPicker({
) : <div className="canvas-model-picker-two-pane">
<div className="canvas-model-picker-brand-rail" aria-label="产品模型">
{optionGroups.map((group) => {
const groupCurrent = group.models.find((item) => item.models.includes(current));
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
return <button key={group.key} type="button" className={cn("canvas-model-picker-brand", activeGroupKey === group.key && "is-active")} aria-pressed={activeGroupKey === group.key} onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); }}>
return <button key={group.key} type="button" className={cn("canvas-model-picker-brand", activeGroupKey === group.key && "is-active")} aria-pressed={activeGroupKey === group.key} onClick={() => setActiveGroupKey(group.key)}>
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</small></span>
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
@@ -207,10 +203,6 @@ export function ModelPicker({
})}
</div>
{optionGroups.filter((group) => group.key === activeGroupKey).map((group) => <section key={group.key} className="canvas-model-picker-group canvas-model-picker-model-pane min-w-0 overflow-hidden">
<div className="canvas-model-picker-secondary-head">
<button type="button" className="canvas-model-picker-back" onClick={() => { setActiveGroupKey(null); focusMenuOption(); }} aria-label="返回模型列表"><ChevronLeft /></button>
<span><strong>{group.label}</strong>{group.scope ? <small>{group.scope}</small> : null}</span>
</div>
<div className="grid min-w-0 gap-1">
{group.models.map((modelGroup) => {
const selected = modelGroup.models.includes(current);
@@ -226,16 +218,11 @@ export function ModelPicker({
aria-selected={selected}
aria-disabled={Boolean(disabledReason)}
disabled={Boolean(disabledReason)}
title={disabledReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)}
className={cn("canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45", previewedModel === displayModel && "is-previewed")}
className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45"
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
onMouseEnter={() => setPreviewedModel(displayModel)}
onFocus={() => setPreviewedModel(displayModel)}
onClick={() => {
if (!model) return;
onChange(model);
setOpen(false);
window.requestAnimationFrame(() => triggerRef.current?.focus());
}}
>
<ModelLabel
@@ -249,9 +236,9 @@ export function ModelPicker({
requirements={requirements}
showPrice={showOptionPrices && creditsEnabled}
disabledReason={disabledReason}
showDescription={selected || previewedModel === displayModel}
showDescription
/>
{selected ? <Check className="canvas-model-picker-option-check ml-1 shrink-0" style={{ color: theme.node.activeStroke }} /> : null}
<span className="canvas-model-picker-option-check ml-1 shrink-0" aria-hidden="true">{selected ? <Check className="size-full" style={{ color: theme.node.activeStroke }} /> : null}</span>
</button>
);
})}
@@ -352,9 +339,10 @@ export function ModelLabel({
</span>
<span className="min-w-44 flex-1 overflow-hidden">
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{label || pickerModelDisplayName(config, model, showConfiguredModelName)}</span>
<span className={cn("canvas-model-picker-description mt-1 block truncate text-[var(--fs-tiny)]", showDescription && "is-visible")} style={{ color: theme.node.muted }} title={capabilitySummary}>
<span className={cn("canvas-model-picker-description mt-1 block truncate text-[var(--fs-tiny)]", showDescription && "is-visible")} style={{ color: theme.node.muted }}>
{capabilitySummary}
</span>
<ModelTags tags={logicalCost?.tags} />
</span>
{showPrice ? (
<span className="ml-auto shrink-0 pl-2">
@@ -509,8 +497,8 @@ function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice |
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
const label = quote.estimated ? `预估:${amount}` : `${amount}`;
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={modelQuoteDescription(quote)}>
<Coins className="size-3" />
<span className="model-picker-price inline-flex shrink-0 items-center gap-1 text-[var(--fs-tiny)] font-bold tabular-nums" aria-label={modelQuoteDescription(quote)}>
<Coins className="model-picker-price-icon" aria-hidden="true" />
{compact ? label : `${label} 积分`}
</span>
);
@@ -519,19 +507,19 @@ function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice |
if (price === null) return compact ? null : <span className="shrink-0 text-[var(--fs-tiny)] text-foreground/40">未配置</span>;
if (price.kind === "tiers") {
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={price.title}>
<Coins className="size-3" />
<span className="model-picker-price inline-flex shrink-0 items-center gap-1 text-[var(--fs-tiny)] font-bold tabular-nums">
<Coins className="model-picker-price-icon" aria-hidden="true" />
{compact ? price.compactLabel : price.label}
</span>
);
}
if (price.kind === "estimate") {
return <span className="shrink-0 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-300" title={price.title}>{price.label || "按量预估"}</span>;
return <span className="model-picker-price inline-flex shrink-0 items-center text-[var(--fs-tiny)] font-semibold"><Coins className="model-picker-price-icon" aria-hidden="true" />{price.label || "按量预估"}</span>;
}
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
<Coins className="size-3" />
{price.value.toLocaleString("zh-CN", { maximumFractionDigits: compact ? 3 : 6 })}/{price.unit}
<span className="model-picker-price inline-flex shrink-0 items-center gap-1 text-[var(--fs-tiny)] font-bold tabular-nums">
<Coins className="model-picker-price-icon" aria-hidden="true" />
{price.value.toLocaleString("zh-CN", { maximumFractionDigits: compact ? 3 : 6 })}{compact ? "/" : " 积分/"}{price.unit}
</span>
);
}

View File

@@ -0,0 +1,9 @@
import type { ModelTag } from "@/lib/model-tags";
import "@/styles/shared/model-tags.css";
export function ModelTags({ tags }: { tags?: ModelTag[] }) {
if (!tags?.length) return null;
return <span className="model-tags" aria-label="模型标签">
{tags.map((tag, index) => <span key={`${tag.text}-${index}`} className="model-tag" data-color={tag.color}>{tag.text}</span>)}
</span>;
}

10
web/src/lib/model-tags.ts Normal file
View File

@@ -0,0 +1,10 @@
export const modelTagColors = [
{ value: "purple", label: "紫色" },
{ value: "blue", label: "蓝色" },
{ value: "green", label: "绿色" },
{ value: "gold", label: "金色" },
{ value: "orange", label: "橙色" },
{ value: "pink", label: "粉色" },
] as const;
export type ModelTag = { text: string; color: typeof modelTagColors[number]["value"] };

View File

@@ -139,6 +139,7 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo
model: model.modelKey,
displayName: model.displayName,
channelLabel: model.channelLabel,
tags: model.tags || [],
description: model.description || "",
icon: model.icon || "",
capability: model.capability as ModelCapability,

View File

@@ -2,6 +2,7 @@ import type { ModelCapabilityChoice } from "@/components/model-protocol-picker";
import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolSupportsTokenBilling, type ModelProtocolDefinition } from "@/lib/model-protocols";
import type { ChannelModel } from "@/services/api/wallet";
import type { ModelTag } from "@/lib/model-tags";
import { defaultPriceTier, legacyPriceTierToForm, priceTierToForm, type PriceTierFormValues } from "./channel-model-price-tier-form";
export type ChannelModelFormValues = {
@@ -9,6 +10,7 @@ export type ChannelModelFormValues = {
providerModelKey?: string;
displayName?: string;
channelLabel?: string;
tags: ModelTag[];
description?: string;
icon?: string;
capability: ModelCapabilityChoice;
@@ -33,6 +35,7 @@ export function initialChannelModelValues(item: ChannelModel | null, protocols:
providerModelKey: upstreamModel,
displayName: item?.displayName || "",
channelLabel: item?.channelLabel || "",
tags: item?.tags?.map((tag) => ({ ...tag })) || [],
description: item?.description || "",
icon: item?.icon || "",
capability,

View File

@@ -13,6 +13,7 @@ import { createAdminChannelModel, testAdminChannelModel, updateAdminChannelModel
import type { ModelChannel } from "@/stores/use-config-store";
import { defaultPriceTier, normalizeUpstreamModelKey, priceTierPayloadFromForm } from "./channel-model-price-tier-form";
import { PriceTierFields } from "./channel-model-price-tier-fields";
import { ChannelModelTagsEditor } from "./channel-model-tags-editor";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
export function ChannelModelEditor({
@@ -112,6 +113,7 @@ export function ChannelModelEditor({
providerModelKey: upstreamModel,
displayName: values.displayName?.trim() || values.modelKey.trim(),
channelLabel: values.channelLabel?.trim() || "",
tags: (values.tags || []).map((tag) => ({ text: tag.text.trim(), color: tag.color })),
description: values.description?.trim() || "",
icon: values.icon?.trim() || "",
capability: values.capability,
@@ -275,7 +277,8 @@ export function ChannelModelEditor({
description="命中的请求会优先使用价格档的上游 ID;修改上方上游模型 ID 时,只有与旧值相同的档位会自动跟随更新,其余保持不变。"
/>
) : null}
<Form.Item name="description" label="模型描述" extra="在创作端二级渠道选项悬浮或聚焦时显示,可说明适用场景、渠道差异和注意事项。" rules={[{ max: 500, message: "模型描述不能超过 500 字" }]}>
<ChannelModelTagsEditor />
<Form.Item name="description" label="模型描述" extra="在创作端二级渠道选项中常驻显示,可说明适用场景、渠道差异和注意事项。" rules={[{ max: 500, message: "模型描述不能超过 500 字" }]}>
<Input.TextArea rows={3} maxLength={500} showCount placeholder="填写此渠道模型的使用说明" />
</Form.Item>
</section>

View File

@@ -0,0 +1,34 @@
import { Button, Form, Input, Select } from "antd";
import { Plus, Trash2 } from "lucide-react";
import { ModelTags } from "@/components/model-tags";
import { modelTagColors, type ModelTag } from "@/lib/model-tags";
export function ChannelModelTagsEditor() {
const form = Form.useFormInstance();
const tags: ModelTag[] = Form.useWatch("tags", form) || [];
return <Form.Item label="展示标签" extra="最多 5 个,每个 12 字;展示在用户的二级渠道选项中,不改变实际计费。">
<Form.List name="tags" rules={[{ validator: async (_, value: ModelTag[] = []) => {
if (value.length > 5) throw new Error("最多添加 5 个标签");
const texts = value.map((tag) => tag?.text?.trim()).filter(Boolean);
if (new Set(texts).size !== texts.length) throw new Error("标签文字不能重复");
} }]}>
{(fields, { add, remove }, { errors }) => <div className="grid gap-2">
{fields.map(({ key, name, ...rest }) => <div key={key} className="flex flex-wrap items-start gap-2">
<Form.Item {...rest} name={[name, "text"]} className="mb-0 min-w-32 flex-1" rules={[{ validator: async (_, value: string) => {
const length = Array.from(value?.trim() || "").length;
if (length < 1 || length > 12) throw new Error("标签文字须为 1–12 字");
} }]}>
<Input aria-label={`标签 ${name + 1} 文字`} placeholder="例如:限时特价、官方1折" />
</Form.Item>
<Form.Item {...rest} name={[name, "color"]} className="mb-0 w-28" rules={[{ required: true, message: "请选择颜色" }]}>
<Select aria-label={`标签 ${name + 1} 颜色`} options={modelTagColors.map((color) => ({ value: color.value, label: <span className="model-tag" data-color={color.value}>{color.label}</span> }))} />
</Form.Item>
<Button type="text" aria-label={`删除标签 ${name + 1}`} icon={<Trash2 className="size-4" />} onClick={() => remove(name)} />
</div>)}
<Button type="dashed" icon={<Plus className="size-4" />} disabled={fields.length >= 5} onClick={() => add({ text: "", color: "purple" })}>添加标签</Button>
<Form.ErrorList errors={errors} />
{tags.some((tag) => tag?.text?.trim()) ? <div><span className="text-xs text-foreground/60">展示预览</span><ModelTags tags={tags.filter((tag) => tag?.text?.trim())} /></div> : null}
</div>}
</Form.List>
</Form.Item>;
}

View File

@@ -1,4 +1,5 @@
import { http } from "@/services/api/request";
import type { ModelTag } from "@/lib/model-tags";
export type InputConstraint = { min: number; max: number };
export type OptionConstraint = { values?: unknown[]; min?: number; max?: number; step?: number };
@@ -168,6 +169,7 @@ export type PublicChannelModel = {
modelKey: string;
displayName: string;
channelLabel?: string;
tags?: ModelTag[];
description?: string;
sortOrder?: number;
icon: string;

View File

@@ -1,4 +1,5 @@
import { http } from "@/services/api/request";
import type { ModelTag } from "@/lib/model-tags";
export type CreditAccount = {
@@ -53,6 +54,7 @@ export type ChannelModel = {
providerModelKey: string;
displayName: string;
channelLabel?: string;
tags?: ModelTag[];
description?: string;
sortOrder?: number;
icon: string;
@@ -109,6 +111,7 @@ export type ChannelModelMutation = {
providerModelKey?: string;
displayName?: string;
channelLabel?: string;
tags?: ModelTag[];
description?: string;
icon?: string;
capability: ChannelModel["capability"];

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react";
import type { ModelTag } from "@/lib/model-tags";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { nanoid } from "nanoid";
@@ -363,6 +364,7 @@ export type ModelChannel = {
model: string;
displayName?: string;
channelLabel?: string;
tags?: ModelTag[];
description?: string;
icon?: string;
capability: ModelCapability;

View File

@@ -119,9 +119,6 @@
transition: background-color 150ms ease, transform 120ms ease;
}
.creation-model-picker-menu .canvas-model-picker-option:hover {
transform: translateY(-1px);
}
.creation-model-picker-menu .canvas-model-picker-option > span:first-child {
gap: 12px;
@@ -359,17 +356,14 @@ html:not(.dark) .creation-model-picker.canvas-composer-model-picker[aria-expande
width: 100%;
}
.canvas-model-picker-option:hover {
background: color-mix(in srgb, var(--workspace-accent) 9%, transparent) !important;
}
.canvas-model-picker-option[aria-selected="true"] {
background: color-mix(in srgb, var(--workspace-accent) 6%, transparent) !important;
}
.canvas-model-picker-option:focus-visible {
background: color-mix(in srgb, var(--workspace-accent) 13%, transparent) !important;
outline: none;
outline: 2px solid var(--control-selected-border);
outline-offset: -2px;
}
.canvas-model-picker-option > span:first-child {
@@ -382,8 +376,8 @@ html:not(.dark) .creation-model-picker.canvas-composer-model-picker[aria-expande
}
.canvas-model-picker-popover .canvas-model-picker-option > span:first-child > span:nth-child(2) {
flex: 0 0 auto;
min-width: max-content;
flex: 1 1 auto;
min-width: 0;
overflow: visible;
}
@@ -552,7 +546,6 @@ html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-m
.creation-model-picker-heading strong { color: #e6e8eb; font-weight: 500; }
.creation-model-picker-menu .canvas-model-picker-group-label { padding: 6px 7px 5px; font-size: 10px; }
.creation-model-picker-menu .canvas-model-picker-option { min-height: 43px; border-radius: 6px; padding: 5px 7px; }
.creation-model-picker-menu .canvas-model-picker-option:hover { transform: none; }
.creation-model-picker-menu .canvas-model-picker-option > span:first-child { gap: 8px; }
.creation-model-picker-menu .canvas-model-picker-option > span:first-child > span:first-child { width: 26px; height: 26px; border: 0; border-radius: 5px; background: rgba(255, 255, 255, .07) !important; }
.creation-model-picker-menu .canvas-model-picker-option > span:first-child > span:nth-child(2) > span:first-child { font-size: 11px; }
@@ -603,10 +596,19 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
text-align: left;
cursor: pointer;
}
.canvas-model-picker-brand:hover,
.canvas-model-picker-brand.is-active {
border-color: var(--workspace-border);
background: var(--workspace-surface-hover);
/* 覆盖菜单外壳的透明底色,选中状态不依赖 hover。 */
.canvas-model-picker-menu .canvas-model-picker-brand[aria-pressed="true"],
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand[aria-pressed="true"] {
border-color: var(--control-selected-border);
background: var(--control-selected-bg);
color: var(--control-selected-fg);
}
.canvas-model-picker-menu .canvas-model-picker-brand[aria-pressed="true"] .canvas-model-picker-brand-arrow {
color: var(--control-selected-fg);
}
.canvas-model-picker-brand:focus-visible {
outline: 2px solid var(--control-selected-border);
outline-offset: 2px;
}
.canvas-model-picker-brand-icon {
display: grid;
@@ -649,7 +651,7 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
width: min(360px, calc(100vw - 24px)) !important;
}
.canvas-model-picker-menu.is-model-list {
width: min(720px, calc(100vw - 24px)) !important;
width: min(800px, calc(100vw - 24px)) !important;
}
.canvas-model-picker-two-pane {
display: grid;
@@ -721,15 +723,6 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
color: color-mix(in srgb, currentColor 52%, transparent);
font-size: 10px;
}
/* 模型下拉:选中项与当前 hover/focus 项展示说明,其他项保持安静。 */
.canvas-model-picker-option {
transition: background-color 160ms ease !important;
}
.canvas-model-picker-option.is-previewed:not(:disabled) {
background: color-mix(in srgb, var(--workspace-accent) 8%, var(--workspace-surface-strong)) !important;
}
.canvas-model-picker-description {
height: 16px;
line-height: 16px;
@@ -743,6 +736,33 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
opacity: 1;
}
/* 积分独立于选中态,金黄色图标与紧凑标签保持常驻可读。 */
.model-picker-price {
--model-price-ink: #946900;
color: var(--model-price-ink);
font-weight: 650;
line-height: 1.3;
gap: 3px;
white-space: nowrap;
}
.dark .model-picker-price {
--model-price-ink: #e8c45a;
}
.model-picker-price-icon {
width: 11px;
height: 11px;
flex: 0 0 11px;
}
.canvas-model-picker-option .model-picker-price {
border: 1px solid color-mix(in srgb, var(--model-price-ink) 20%, transparent);
border-radius: var(--r-sm);
background: color-mix(in srgb, var(--model-price-ink) 9%, transparent);
padding: 2px 5px;
}
@media (prefers-reduced-motion: reduce) {
.canvas-model-picker-option,
.canvas-model-picker-description { transition: none !important; }
@@ -782,7 +802,6 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-subheading { display: flex; align-items: baseline; justify-content: space-between; padding: 3px 5px 8px; color: var(--user-ink); font-size: 12px; font-weight: 650; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-subheading small { color: var(--user-ink-soft); font-size: 10px; font-weight: 500; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--user-ink); text-align: left; transition: background-color 160ms ease, border-color 160ms ease; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand:hover { border-color: var(--user-border); background: var(--user-surface-hover); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; }
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
@@ -823,7 +842,6 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.creation-model-picker-surface .canvas-model-picker-subheading { display: flex; align-items: baseline; justify-content: space-between; padding: 3px 5px 8px; color: var(--foreground); font-size: 12px; font-weight: 650; }
.creation-model-picker-surface .canvas-model-picker-subheading small { color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: 10px; font-weight: 500; }
.creation-model-picker-surface .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--foreground); text-align: left; transition: background-color 160ms ease, border-color 160ms ease; }
.creation-model-picker-surface .canvas-model-picker-brand:hover { border-color: var(--workspace-border); background: var(--workspace-surface-hover); }
.creation-model-picker-surface .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--workspace-surface-hover); color: var(--foreground); }
.creation-model-picker-surface .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; }
.creation-model-picker-surface .canvas-model-picker-brand-copy strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
@@ -840,4 +858,4 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.creation-model-picker-surface .canvas-model-picker-option > span:first-child > span:first-child svg { color: currentColor !important; opacity: 1 !important; }
.creation-model-picker-surface .canvas-model-picker-option:nth-child(3n) > span:first-child > span:first-child { color: #d57b16; }
.creation-model-picker-surface .canvas-model-picker-option:nth-child(4n) > span:first-child > span:first-child { color: #25845d; }
.creation-model-picker-surface .canvas-model-picker-option .text-amber-600 { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; background: color-mix(in srgb, #e28a17 12%, var(--workspace-surface-strong)); color: #c66d00 !important; padding: 5px 8px; font-size: 11px; font-weight: 700; }
.creation-model-picker-surface .canvas-model-picker-option .model-picker-price { font-size: var(--fs-tiny); }

View File

@@ -0,0 +1,13 @@
.model-tags { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; margin-top: 6px; }
.model-tag { --model-tag-ink: #6850a1; display: inline-flex; align-items: center; max-width: 100%; padding: 2px 6px; border: 1px solid color-mix(in srgb, var(--model-tag-ink) 22%, transparent); border-radius: var(--r-sm); color: var(--model-tag-ink); background: color-mix(in srgb, var(--model-tag-ink) 9%, transparent); font-size: var(--fs-tiny); font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
.model-tag[data-color="blue"] { --model-tag-ink: #3562b1; }
.model-tag[data-color="green"] { --model-tag-ink: #26745b; }
.model-tag[data-color="gold"] { --model-tag-ink: #886310; }
.model-tag[data-color="orange"] { --model-tag-ink: #a75321; }
.model-tag[data-color="pink"] { --model-tag-ink: #a43e76; }
.dark .model-tag { --model-tag-ink: #c8b6ed; }
.dark .model-tag[data-color="blue"] { --model-tag-ink: #a3c3f5; }
.dark .model-tag[data-color="green"] { --model-tag-ink: #94d6b9; }
.dark .model-tag[data-color="gold"] { --model-tag-ink: #e6cb83; }
.dark .model-tag[data-color="orange"] { --model-tag-ink: #edb08b; }
.dark .model-tag[data-color="pink"] { --model-tag-ink: #e8acce; }

View File

@@ -588,7 +588,6 @@
.creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-two-pane { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: 14px; min-height: 0; align-items: start; }
.creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-brand-rail { display: grid; align-content: start; gap: 5px; border-right: 1px solid var(--workspace-border); padding-right: 14px; }
.creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-brand { width: 100%; }
.creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-brand.is-active { border-color: var(--workspace-border); background: var(--workspace-surface-hover); }
.creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-model-pane { border: 0; margin: 0; padding: 0; }
@media (max-width: 720px) { .creation-floating-prompt { top: 8px; width: calc(100% - 24px); min-height: 60px; gap: 10px; padding: 8px 9px 8px 16px; border-radius: 19px; } .creation-floating-prompt input { font-size: 14px; } .creation-floating-prompt button { width: 40px; height: 40px; flex-basis: 40px; } .creation-model-picker-surface .creation-model-picker-menu:is(.is-brand-list, .is-model-list) { width: calc(100vw - 24px) !important; } .creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-two-pane { grid-template-columns: minmax(0, 1fr); min-height: 0; } .creation-model-picker-surface .creation-model-picker-menu.is-model-list .canvas-model-picker-brand-rail { display: none; } .app-user-workspace .creation-chat-composer.is-thread .creation-chat-dock .creation-mode-tabs { height: 30px; } .app-user-workspace .creation-chat-composer.is-thread .creation-chat-dock .creation-mode-button { height: 26px; min-height: 26px; padding: 0 6px; font-size: 10px; } }

View File

@@ -11,10 +11,12 @@ describe("channel model editor drafts", () => {
test("new drafts select an enabled protocol and never share price state", () => {
const first = initialChannelModelValues(null, protocols);
first.priceTiers[0].unitPrice = 42;
first.tags.push({ text: "限时特价", color: "purple" });
const second = initialChannelModelValues(null, protocols);
expect(second.protocol).toBe("text");
expect(second.priceTiers[0].unitPrice).toBe(0);
expect(second.modelKey).toBe("");
expect(second.tags).toEqual([]);
});
test("missing catalogs do not invent a protocol", () => {
expect(initialChannelModelValues(null, []).protocol).toBeUndefined();

View File

@@ -53,6 +53,16 @@ test("grouping uses display name even when model keys differ, without merging ch
expect(groups[0].models.map((item) => item.label)).toEqual(["秘塔(满血渠道)", "秘塔(满血渠道)"]);
});
test("catalog projection keeps promotional tags scoped to each channel model", () => {
const channels = systemChannelModelChannels([
{ id: "a", name: "A", displayName: "A", models: [{ ...model("A", 100_000), tags: [{ text: "限时特价", color: "purple" }] }] },
{ id: "b", name: "B", displayName: "B", models: [{ ...model("B", 200_000), tags: [{ text: "官方1折", color: "gold" }] }] },
]);
const config = normalizeConfigSnapshot({ config: { ...defaultConfig, channels } }).config;
expect(config.channels[0].modelCosts![0].tags).toEqual([{ text: "限时特价", color: "purple" }]);
expect(config.channels[1].modelCosts![0].tags).toEqual([{ text: "官方1折", color: "gold" }]);
});
test("selection and quote keep the chosen channel even when another channel is cheaper", () => {
const config = fixture();
const value = "b::seedance-2.0";

View File

@@ -56,6 +56,20 @@ test("candidate rows do not disguise missing prices as zero", () => {
expect(markup).not.toContain("0 积分");
});
test("model rows preserve multiple promotional labels and colors without altering prices", () => {
const config = fixture();
config.channels[0].modelCosts![0].tags = [{ text: "限时特价", color: "purple" }, { text: "官方1折", color: "gold" }];
for (const theme of [canvasThemes.light, canvasThemes.dark]) {
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={theme} creationVariant showConfiguredModelName={false} showPrice showDescription />);
expect(markup).toContain('data-color="purple">限时特价');
expect(markup).toContain('data-color="gold">官方1折');
expect(markup).toContain("0.1-0.2 积分/秒");
}
config.channels[0].modelCosts![0].tags = [];
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.light} showDescription />);
expect(markup).not.toContain('aria-label="模型标签"');
});
test("the selected model and request estimate still require an exact specification match", () => {
const config = fixture();
const render = (value: ModelRequirements) => renderToStaticMarkup(<ModelPicker config={config} value={config.model} capability="video" requirements={value} onChange={() => {}} />);

View File

@@ -1,5 +1,49 @@
import { expect, test } from "bun:test";
test("模型行只保留选中高亮,价格使用独立的彩色标签", async () => {
const [component, styles, workspace] = await Promise.all([
Bun.file(new URL("../src/components/model-picker.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/styles/shared/model-picker.css", import.meta.url)).text(),
Bun.file(new URL("../src/styles/workspace-product.css", import.meta.url)).text(),
]);
expect(component).not.toContain("previewedModel");
expect(component).not.toContain("onMouseEnter");
expect(styles).not.toMatch(/canvas-model-picker-(?:brand|option)(?:\[[^\]]*\])?:hover/);
expect(workspace).not.toContain(".canvas-model-picker-brand.is-active");
expect(styles).toContain('.canvas-model-picker-brand[aria-pressed="true"]');
expect(styles).toContain('.canvas-model-picker-option[aria-selected="true"]');
expect(styles).toContain(".canvas-model-picker-option:focus-visible");
const price = styles.match(/\.model-picker-price \{([^}]+)\}/)?.[1] || "";
expect(price).toContain("color: var(--model-price-ink)");
expect(price).toContain("font-weight: 650");
expect(price).not.toContain("background:");
expect(styles).toContain(".dark .model-picker-price");
const badge = styles.match(/\.canvas-model-picker-option \.model-picker-price \{([^}]+)\}/)?.[1] || "";
expect(badge).toContain("border-radius: var(--r-sm)");
expect(badge).toContain("background: color-mix");
expect(badge).toContain("padding: 2px 5px");
expect(price).toContain("--model-price-ink: #946900");
expect(component).toContain('<Coins className="model-picker-price-icon" aria-hidden="true" />');
expect(styles).toContain("width: min(800px, calc(100vw - 24px))");
});
test("每次打开菜单都展开当前选中模型所属目录,无有效选中时显示一级目录", async () => {
const component = await Bun.file(new URL("../src/components/model-picker.tsx", import.meta.url)).text();
const opening = component.match(/const setPickerOpen = \(nextOpen: boolean\) => \{([\s\S]*?)\n \};/)?.[1] || "";
expect(opening).toContain("setActiveGroupKey(optionGroups.find((group) => group.models.some((item) => item.models.includes(current)))?.key ?? null)");
expect(opening).not.toContain("setActiveGroupKey(null)");
});
test("选择模型保留菜单及行内焦点,仍可通过 Escape 和外部点击关闭", async () => {
const component = await Bun.file(new URL("../src/components/model-picker.tsx", import.meta.url)).text();
const selection = component.match(/onClick=\{\(\) => \{\s*if \(!model\) return;([\s\S]*?)\}\}/)?.[1] || "";
expect(selection).toContain("onChange(model)");
expect(selection).not.toContain("setOpen(false)");
expect(selection).not.toContain("focus()");
expect(component).toContain('event.key === "Escape"');
expect(component).toContain('window.addEventListener("pointerdown", closeOnOutsidePointer, true)');
});
test("ModelPicker 样式独立加载,并保留模型列表的视口边界", async () => {
const [application, globals, pickerStyles] = await Promise.all([
Bun.file(new URL("../src/application.tsx", import.meta.url)).text(),