ai-chatbot/components/agents/AgentWorkflowMaterialSelectionPanel.tsx
Your Name 01748fe501
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: harden psd text workflow compatibility
2026-04-02 00:08:53 +08:00

775 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import { Plus, Sparkles } from "lucide-react";
import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import { cn } from "@/lib/utils";
import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel";
import type {
AgentWorkflowMaterialImage,
AgentWorkflowMaterialSearchResult,
AgentWorkflowMaterialQueryType,
AgentWorkflowMaterialSelections,
} from "@/types/agent";
interface AgentWorkflowMaterialSelectionPanelProps {
inputs: AgentWorkflowInputs;
onInputsChange: (next: AgentWorkflowInputs) => void;
materials: AgentWorkflowMaterialSelections;
onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void;
productSelectionLimit?: number | null;
title?: string;
description?: string;
disabled?: boolean;
disabledHint?: string;
theme?: "light" | "dark";
}
function getUploadedWorkflowMaterialImages(
result?: AgentWorkflowMaterialSearchResult | null
): AgentWorkflowMaterialImage[] {
return Array.isArray(result?.images)
? result.images
.filter((item) => item.sourceType === "upload")
.slice()
.sort((left, right) => left.sortOrder - right.sortOrder)
: [];
}
function markSearchWorkflowMaterialImages(
images: AgentWorkflowMaterialImage[]
): AgentWorkflowMaterialImage[] {
return images.map((image) => ({
...image,
sourceType: image.sourceType === "upload" ? "upload" : "search",
}));
}
function buildWorkflowMaterialResult(params: {
currentResult?: AgentWorkflowMaterialSearchResult | null;
images: AgentWorkflowMaterialImage[];
fallbackQuery: string;
fallbackQueryType: AgentWorkflowMaterialQueryType;
}): AgentWorkflowMaterialSearchResult {
const normalizedImages: AgentWorkflowMaterialImage[] = params.images.map((image, index) => ({
...image,
sourceType: image.sourceType === "upload" ? "upload" : "search",
sortOrder: index,
}));
const validIds = new Set(normalizedImages.map((image) => image.id));
const normalizedQuery = params.fallbackQuery.trim().toUpperCase();
const currentResult = params.currentResult;
const queryType = currentResult?.queryType || params.fallbackQueryType;
const hasUploadedImages = normalizedImages.some((image) => image.sourceType === "upload");
const fallbackMatchedSku =
queryType === "sku"
? currentResult?.matchedSku || normalizedQuery || "手动上传素材"
: currentResult?.matchedSku || (hasUploadedImages ? "手动上传素材" : "");
const fallbackMatchedSpu =
currentResult?.matchedSpu ||
(queryType === "spu" ? normalizedQuery || (hasUploadedImages ? "手动上传素材" : "") : null);
return {
query: currentResult?.query || normalizedQuery || "手动上传素材",
queryType,
matchedSku: currentResult?.matchedSku || fallbackMatchedSku,
matchedSpu: fallbackMatchedSpu,
materialId: currentResult?.materialId || null,
productModel:
currentResult?.productModel ||
fallbackMatchedSpu ||
currentResult?.matchedSku ||
fallbackMatchedSku ||
null,
zipUrl: currentResult?.zipUrl || null,
fixedCount:
normalizedImages.length > 0
? normalizedImages.filter((image) => image.group === "fixed").length
: currentResult?.fixedCount || 0,
otherCount:
normalizedImages.length > 0
? normalizedImages.filter((image) => image.group === "other").length
: currentResult?.otherCount || 0,
totalCount:
normalizedImages.length > 0
? normalizedImages.length
: currentResult?.totalCount || 0,
images: normalizedImages,
recommendedImageIds: Array.isArray(currentResult?.recommendedImageIds)
? currentResult!.recommendedImageIds.filter((id) => validIds.has(id))
: [],
skuOptions: currentResult?.skuOptions || [],
goodsInfo: currentResult?.goodsInfo || null,
};
}
function finalizeWorkflowMaterialResult(
result: AgentWorkflowMaterialSearchResult | null
): AgentWorkflowMaterialSearchResult | null {
if (!result) return null;
const hasImages = Array.isArray(result.images) && result.images.length > 0;
const hasSkuOptions = Array.isArray(result.skuOptions) && result.skuOptions.length > 0;
return hasImages || hasSkuOptions ? result : null;
}
function mergeSearchResultWithUploadedImages(params: {
searchResult: AgentWorkflowMaterialSearchResult;
currentResult?: AgentWorkflowMaterialSearchResult | null;
}): AgentWorkflowMaterialSearchResult {
const uploadedImages = getUploadedWorkflowMaterialImages(params.currentResult);
const searchImages = markSearchWorkflowMaterialImages(params.searchResult.images || []);
return buildWorkflowMaterialResult({
currentResult: {
...params.searchResult,
images: [...uploadedImages, ...searchImages],
},
images: [...uploadedImages, ...searchImages],
fallbackQuery: params.searchResult.query,
fallbackQueryType: params.searchResult.queryType === "spu" ? "spu" : "sku",
});
}
export function AgentWorkflowMaterialSelectionPanel({
inputs,
onInputsChange,
materials,
onMaterialsChange,
productSelectionLimit = null,
title,
description,
disabled = false,
disabledHint = "请先在下方选择一个生图模式,再启用 SKU / SPU 搜图。",
theme = "light",
}: AgentWorkflowMaterialSelectionPanelProps) {
const hasSecondaryMaterialContent =
Boolean(String(inputs.secondarySku || "").trim()) ||
Boolean(materials.secondary.result) ||
materials.secondary.selectedImageIds.length > 0;
const buildLoadingMaterialResult = (params: {
keyword: string;
currentResult?: AgentWorkflowMaterialSearchResult | null;
placeholderCount: number;
}): AgentWorkflowMaterialSearchResult => {
const count = Math.max(4, Math.min(params.placeholderCount || 8, 16));
return {
query: params.keyword,
queryType: "sku",
matchedSku: params.keyword,
matchedSpu: params.currentResult?.matchedSpu || null,
materialId: params.currentResult?.materialId || null,
productModel: params.currentResult?.productModel || null,
zipUrl: null,
fixedCount: count,
otherCount: 0,
totalCount: count,
skuOptions: [],
recommendedImageIds: [],
images: Array.from({ length: count }, (_, index) => ({
id: `loading-${params.keyword}-${index + 1}`,
key: `loading-${params.keyword}-${index + 1}`,
label: `加载中 ${index + 1}`,
fileName: `正在加载白底图 ${index + 1}`,
imageUrl: "",
assetId: null,
mimeType: null,
sourceType: "search",
group: "fixed",
sortOrder: index,
})),
};
};
const [materialSearchState, setMaterialSearchState] = useState<{
primary: { isLoading: boolean; errorText: string | null };
secondary: { isLoading: boolean; errorText: string | null };
}>({
primary: { isLoading: false, errorText: null },
secondary: { isLoading: false, errorText: null },
});
const [materialUploadState, setMaterialUploadState] = useState<{
primary: { isUploadingNew: boolean; uploadingImageId: string | null };
secondary: { isUploadingNew: boolean; uploadingImageId: string | null };
}>({
primary: { isUploadingNew: false, uploadingImageId: null },
secondary: { isUploadingNew: false, uploadingImageId: null },
});
const [materialSearchMode, setMaterialSearchMode] = useState<{
primary: AgentWorkflowMaterialQueryType;
secondary: AgentWorkflowMaterialQueryType;
}>({
primary: "sku",
secondary: "sku",
});
const [isSecondaryMaterialVisible, setIsSecondaryMaterialVisible] = useState(
hasSecondaryMaterialContent
);
const primarySelectedCount = materials.primary.selectedImageIds.length;
const secondarySelectedCount = materials.secondary.selectedImageIds.length;
const primarySelectionLimit =
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? Math.max(productSelectionLimit - secondarySelectedCount, primarySelectedCount, 0)
: null;
const secondarySelectionLimit =
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? Math.max(productSelectionLimit - primarySelectedCount, secondarySelectedCount, 0)
: null;
useEffect(() => {
if (hasSecondaryMaterialContent) {
setIsSecondaryMaterialVisible(true);
}
}, [hasSecondaryMaterialContent]);
const updateMaterialSearchState = (
source: "primary" | "secondary",
updates: Partial<{ isLoading: boolean; errorText: string | null }>
) => {
setMaterialSearchState((prev) => ({
...prev,
[source]: {
...prev[source],
...updates,
},
}));
};
const updateMaterialUploadState = (
source: "primary" | "secondary",
updates: Partial<{ isUploadingNew: boolean; uploadingImageId: string | null }>
) => {
setMaterialUploadState((prev) => ({
...prev,
[source]: {
...prev[source],
...updates,
},
}));
};
const getFallbackMaterialQuery = (source: "primary" | "secondary") => {
const currentResult = materials[source].result;
const rawInput = String(source === "primary" ? inputs.primarySku : inputs.secondarySku).trim();
return (currentResult?.query || rawInput || "手动上传素材").trim();
};
const getFallbackMaterialQueryType = (source: "primary" | "secondary") => {
return materials[source].result?.queryType || materialSearchMode[source];
};
const handleMaterialInputChange = (source: "primary" | "secondary", value: string) => {
const inputKey = source === "primary" ? "primarySku" : "secondarySku";
const trimmedValue = value.trim().toUpperCase();
const currentQuery = String(materials[source]?.result?.query || "").trim().toUpperCase();
onInputsChange({
...inputs,
[inputKey]: value,
});
if (
trimmedValue !== currentQuery &&
(materials[source]?.result || materials[source]?.selectedImageIds.length)
) {
onMaterialsChange({
...materials,
[source]: createEmptyAgentWorkflowMaterialSelection(),
});
}
if (materialSearchState[source].errorText) {
updateMaterialSearchState(source, { errorText: null });
}
};
const handleSearchMaterials = async (
source: "primary" | "secondary",
options?: {
keyword?: string;
queryType?: AgentWorkflowMaterialQueryType;
}
) => {
const keyword = String(options?.keyword || (source === "primary" ? inputs.primarySku : inputs.secondarySku)).trim();
const queryType = options?.queryType || materialSearchMode[source];
if (!keyword) {
updateMaterialSearchState(source, { errorText: "请先输入搜索关键词" });
return;
}
try {
updateMaterialSearchState(source, { isLoading: true, errorText: null });
const searchParams = new URLSearchParams({
keyword,
source,
queryType,
});
const currentSelectionLimit =
source === "primary" ? primarySelectionLimit : secondarySelectionLimit;
const requiredCount =
typeof currentSelectionLimit === "number" &&
Number.isFinite(currentSelectionLimit) &&
currentSelectionLimit > 0
? Math.round(currentSelectionLimit)
: null;
if (requiredCount) {
searchParams.set("requiredCount", String(requiredCount));
}
const response = await fetch(
`/api/agent-workflow/material-search?${searchParams.toString()}`,
{ cache: "no-store" }
);
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.success || !result?.data) {
throw new Error(result?.error?.message || result?.error || "搜索失败");
}
const mergedResult = mergeSearchResultWithUploadedImages({
searchResult: result.data,
currentResult: materials[source].result,
});
const preservedUploadedIds = getUploadedWorkflowMaterialImages(materials[source].result)
.map((image) => image.id)
.filter((id) => materials[source].selectedImageIds.includes(id));
onMaterialsChange({
...materials,
[source]: {
result: mergedResult,
selectedImageIds: Array.from(
new Set([
...(Array.isArray(result.data?.recommendedImageIds)
? result.data.recommendedImageIds
: []),
...preservedUploadedIds,
])
).filter((id) => mergedResult.images.some((image) => image.id === id)),
},
});
} catch (error) {
updateMaterialSearchState(source, {
errorText: error instanceof Error ? error.message : "搜索失败",
});
} finally {
updateMaterialSearchState(source, { isLoading: false });
}
};
const handleUploadMaterialImage = async (
source: "primary" | "secondary",
file: File,
options?: {
replaceImageId?: string;
}
) => {
const currentResult = materials[source].result;
const query = getFallbackMaterialQuery(source);
const queryType = getFallbackMaterialQueryType(source);
const matchedSku =
currentResult?.matchedSku ||
(queryType === "sku"
? String(source === "primary" ? inputs.primarySku : inputs.secondarySku).trim().toUpperCase()
: "");
const matchedSpu =
currentResult?.matchedSpu ||
(queryType === "spu"
? String(source === "primary" ? inputs.primarySku : inputs.secondarySku).trim().toUpperCase()
: "");
updateMaterialUploadState(source, {
isUploadingNew: !options?.replaceImageId,
uploadingImageId: options?.replaceImageId || null,
});
try {
const formData = new FormData();
formData.set("file", file);
formData.set("source", source);
formData.set("query", query);
formData.set("queryType", queryType);
if (matchedSku) {
formData.set("matchedSku", matchedSku);
}
if (matchedSpu) {
formData.set("matchedSpu", matchedSpu);
}
if (currentResult?.productModel) {
formData.set("productModel", currentResult.productModel);
}
const response = await fetch("/api/agent-workflow/material-upload", {
method: "POST",
body: formData,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success || !payload?.data?.imageUrl) {
throw new Error(payload?.detail || payload?.error || "图片上传失败");
}
const uploadedImage = payload.data as AgentWorkflowMaterialImage;
const currentUploads = getUploadedWorkflowMaterialImages(currentResult).filter(
(image) => image.id !== options?.replaceImageId
);
const searchImages = markSearchWorkflowMaterialImages(
(currentResult?.images || []).filter((image) => image.sourceType !== "upload")
);
const nextResult = buildWorkflowMaterialResult({
currentResult,
images: [...currentUploads, { ...uploadedImage, sourceType: "upload" }, ...searchImages],
fallbackQuery: query,
fallbackQueryType: queryType,
});
const nextSelectedIds = Array.from(
new Set([
...materials[source].selectedImageIds.filter(
(id) => id !== options?.replaceImageId && nextResult.images.some((image) => image.id === id)
),
uploadedImage.id,
])
);
onMaterialsChange({
...materials,
[source]: {
result: nextResult,
selectedImageIds: nextSelectedIds,
},
});
} finally {
updateMaterialUploadState(source, {
isUploadingNew: false,
uploadingImageId: null,
});
}
};
const handleRemoveUploadedMaterialImage = (
source: "primary" | "secondary",
imageId: string
) => {
const currentResult = materials[source].result;
if (!currentResult) {
return;
}
const nextUploads = getUploadedWorkflowMaterialImages(currentResult).filter(
(image) => image.id !== imageId
);
const searchImages = markSearchWorkflowMaterialImages(
(currentResult.images || []).filter((image) => image.sourceType !== "upload")
);
const nextResult = finalizeWorkflowMaterialResult(
buildWorkflowMaterialResult({
currentResult,
images: [...nextUploads, ...searchImages],
fallbackQuery: getFallbackMaterialQuery(source),
fallbackQueryType: getFallbackMaterialQueryType(source),
})
);
onMaterialsChange({
...materials,
[source]: {
result: nextResult,
selectedImageIds: materials[source].selectedImageIds.filter(
(id) => id !== imageId && (nextResult?.images || []).some((image) => image.id === id)
),
},
});
};
const isDark = theme === "dark";
return (
<div
className={cn(
"space-y-3 rounded-[28px] border p-4",
isDark ? "border-white/12 bg-white/[0.04]" : "border-[#dbe3ff] bg-[#f8faff]"
)}
>
{title || description ? (
<div className="space-y-1">
{title ? (
<div
className={cn(
"flex items-center gap-2 text-sm font-medium",
isDark ? "text-white/88" : "text-[#334155]"
)}
>
<Sparkles className={cn("h-4 w-4", isDark ? "text-emerald-300" : "text-[#4f46e5]")} />
<span>{title}</span>
</div>
) : null}
{description ? (
<div
className={cn(
"text-xs leading-5",
isDark ? "text-white/55" : "text-[#5b6472]"
)}
>
{description}
</div>
) : null}
</div>
) : null}
<AgentWorkflowMaterialPicker
value={inputs.primarySku}
queryMode={materialSearchMode.primary}
placeholder={materialSearchMode.primary === "spu" ? "输入主 SPU" : "输入主 SKU"}
buttonLabel={materialSearchMode.primary === "spu" ? "查SKU列表" : "搜索主图"}
searchNote={
materialSearchMode.primary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,点选某个 SKU 后再继续拉取白底图。"
: "按 SKU 搜索会直接拉取白底图,适合你已经知道具体 SKU 的情况。"
}
disabled={disabled}
disabledHint={disabledHint}
isLoading={materialSearchState.primary.isLoading}
errorText={materialSearchState.primary.errorText}
result={materials.primary.result}
selectedImageIds={materials.primary.selectedImageIds}
selectionLimit={primarySelectionLimit}
selectionLimitLabel={
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? `当前模式产品图总上限 ${productSelectionLimit} 张`
: undefined
}
onValueChange={(value) => handleMaterialInputChange("primary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
...prev,
primary: nextMode,
}))
}
onSearch={() => {
void handleSearchMaterials("primary");
}}
onUploadImage={(file, options) => handleUploadMaterialImage("primary", file, options)}
onRemoveUploadedImage={(imageId) =>
handleRemoveUploadedMaterialImage("primary", imageId)
}
isUploadingNewImage={materialUploadState.primary.isUploadingNew}
uploadingImageId={materialUploadState.primary.uploadingImageId}
onSkuOptionSelect={(skuName) => {
const selectedOption = materials.primary.result?.skuOptions?.find(
(option) => option.skuName === skuName
);
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
const uploadedImages = getUploadedWorkflowMaterialImages(materials.primary.result);
onInputsChange({
...inputs,
primarySku: skuName,
});
onMaterialsChange({
...materials,
primary: {
result: buildWorkflowMaterialResult({
currentResult: {
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.primary.result,
placeholderCount,
}),
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.primary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
},
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.primary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
fallbackQuery: skuName,
fallbackQueryType: "sku",
}),
selectedImageIds: materials.primary.selectedImageIds.filter((id) =>
uploadedImages.some((image) => image.id === id)
),
},
});
setMaterialSearchMode((prev) => ({
...prev,
primary: "sku",
}));
void handleSearchMaterials("primary", {
keyword: skuName,
queryType: "sku",
});
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
primary: {
...materials.primary,
selectedImageIds: next,
},
})
}
/>
{isSecondaryMaterialVisible ? (
<AgentWorkflowMaterialPicker
value={inputs.secondarySku}
queryMode={materialSearchMode.secondary}
placeholder={materialSearchMode.secondary === "spu" ? "输入搭配 SPU" : "输入搭配 SKU"}
buttonLabel={materialSearchMode.secondary === "spu" ? "查SKU列表" : "搜索搭配图"}
searchNote={
materialSearchMode.secondary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,再选具体 SKU 获取白底图。"
: "选中的搭配图会进入第二个产品槽位,你也可以在下面继续手动替换。"
}
disabled={disabled}
disabledHint={disabledHint}
isLoading={materialSearchState.secondary.isLoading}
errorText={materialSearchState.secondary.errorText}
result={materials.secondary.result}
selectedImageIds={materials.secondary.selectedImageIds}
selectionLimit={secondarySelectionLimit}
selectionLimitLabel={
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? `当前模式产品图总上限 ${productSelectionLimit} 张`
: undefined
}
onValueChange={(value) => handleMaterialInputChange("secondary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
...prev,
secondary: nextMode,
}))
}
onSearch={() => {
void handleSearchMaterials("secondary");
}}
onUploadImage={(file, options) => handleUploadMaterialImage("secondary", file, options)}
onRemoveUploadedImage={(imageId) =>
handleRemoveUploadedMaterialImage("secondary", imageId)
}
isUploadingNewImage={materialUploadState.secondary.isUploadingNew}
uploadingImageId={materialUploadState.secondary.uploadingImageId}
onSkuOptionSelect={(skuName) => {
const selectedOption = materials.secondary.result?.skuOptions?.find(
(option) => option.skuName === skuName
);
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
const uploadedImages = getUploadedWorkflowMaterialImages(materials.secondary.result);
onInputsChange({
...inputs,
secondarySku: skuName,
});
onMaterialsChange({
...materials,
secondary: {
result: buildWorkflowMaterialResult({
currentResult: {
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
placeholderCount,
}),
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
},
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
fallbackQuery: skuName,
fallbackQueryType: "sku",
}),
selectedImageIds: materials.secondary.selectedImageIds.filter((id) =>
uploadedImages.some((image) => image.id === id)
),
},
});
setMaterialSearchMode((prev) => ({
...prev,
secondary: "sku",
}));
void handleSearchMaterials("secondary", {
keyword: skuName,
queryType: "sku",
});
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
secondary: {
...materials.secondary,
selectedImageIds: next,
},
})
}
/>
) : (
<button
type="button"
onClick={() => setIsSecondaryMaterialVisible(true)}
disabled={disabled}
className={cn(
"flex w-full items-center justify-between rounded-[24px] border border-dashed px-4 py-3 text-left transition-colors",
disabled
? isDark
? "cursor-not-allowed border-white/10 bg-white/[0.02] text-white/30"
: "cursor-not-allowed border-[#dbe3ff] bg-[#f8faff] text-[#94a3b8]"
: isDark
? "border-white/14 bg-white/[0.03] text-white/78 hover:border-emerald-300/30 hover:bg-white/[0.05]"
: "border-[#c7d2fe] bg-white text-[#334155] hover:border-[#818cf8] hover:bg-[#f8faff]"
)}
>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<Plus className={cn("h-4 w-4", isDark ? "text-emerald-300" : "text-[#4f46e5]")} />
<span>添加搭配SKU</span>
</div>
<div className={cn("text-xs leading-5", isDark ? "text-white/45" : "text-[#64748b]")}>
需要补充第二个产品图时再展开这个模块,不使用时默认保持隐藏。
</div>
</div>
<span
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-medium",
isDark ? "bg-white/[0.06] text-white/72" : "bg-[#eef2ff] text-[#4f46e5]"
)}
>
可选
</span>
</button>
)}
</div>
);
}