feat(canvas): 模型选择 - 支持彩色展示标签并优化目录与积分交互
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
9
web/src/components/model-tags.tsx
Normal file
9
web/src/components/model-tags.tsx
Normal 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
10
web/src/lib/model-tags.ts
Normal 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"] };
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
web/src/pages/admin/components/channel-model-tags-editor.tsx
Normal file
34
web/src/pages/admin/components/channel-model-tags-editor.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
13
web/src/styles/shared/model-tags.css
Normal file
13
web/src/styles/shared/model-tags.css
Normal 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; }
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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={() => {}} />);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user