feat(canvas): add icon picker and compact skill tags
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-04 00:55:13 +08:00
parent f0d092cc87
commit 94b27f25d5
4 changed files with 206 additions and 84 deletions

View File

@ -94,6 +94,10 @@ import {
normalizeCanvasSkillTags,
type CanvasSkillTag,
} from "@/lib/canvas-skill-tags";
import {
CANVAS_SKILL_ICON_OPTIONS,
getCanvasSkillIconComponent,
} from "@/components/canvas/canvas-skill-icons";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const DEFAULT_RUNWARE_REALTIME_MODEL = "runware:101@1";
@ -104,6 +108,8 @@ function createEmptyCanvasSkillTag(): CanvasSkillTag {
id: `tag-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: "",
prompt: "",
placeholder: "",
iconName: "message-circle",
iconUrl: "",
};
}
@ -1081,7 +1087,7 @@ export function AdminSettingsClient() {
const handleCanvasSkillTagChange = (
index: number,
field: "name" | "prompt" | "iconUrl",
field: "name" | "prompt" | "placeholder" | "iconName" | "iconUrl",
value: string
) => {
setCanvasSkillTags((prev) =>
@ -5737,86 +5743,124 @@ export function AdminSettingsClient() {
<CardHeader>
<CardTitle className="text-xl">画布对话快捷标签</CardTitle>
<CardDescription>
每个标签支持「图标 + 标签名 + 预设提示词」。用户点击标签后,发送消息时会自动附带对应提示词。
每个标签支持「图标 + 标签名 + 输入框提示词 + 预设提示词」。用户点击标签后,输入框会显示对应灰色提示,发送时会自动附带对应预设提示词。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{canvasSkillTags.map((tag, index) => (
<div key={tag.id || index} className="space-y-2 rounded-lg border p-3">
<div className="flex items-center gap-2">
<Input
value={tag.name}
onChange={(event) =>
handleCanvasSkillTagChange(index, "name", event.target.value)
}
placeholder={`标签名称 ${index + 1}`}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveCanvasSkillTag(index)}
disabled={canvasSkillTags.length <= 1}
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-md border bg-muted/40">
{tag.iconUrl ? (
<img
src={tag.iconUrl}
alt={tag.name || `标签${index + 1}`}
className="h-full w-full object-cover"
/>
) : (
<ImageIcon className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex flex-1 items-center gap-2">
{canvasSkillTags.map((tag, index) => {
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
return (
<div key={tag.id || index} className="space-y-2 rounded-lg border p-3">
<div className="flex items-center gap-2">
<Input
value={tag.iconUrl || ""}
value={tag.name}
onChange={(event) =>
handleCanvasSkillTagChange(index, "iconUrl", event.target.value)
handleCanvasSkillTagChange(index, "name", event.target.value)
}
placeholder="图标 URL(可选)"
placeholder={`标签名称 ${index + 1}`}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => document.getElementById(`canvas-skill-tag-icon-${tag.id}`)?.click()}
disabled={canvasSkillTagUploadingId === tag.id}
variant="ghost"
size="icon"
onClick={() => handleRemoveCanvasSkillTag(index)}
disabled={canvasSkillTags.length <= 1}
>
{canvasSkillTagUploadingId === tag.id ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : null}
上传图标
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
<input
id={`canvas-skill-tag-icon-${tag.id}`}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void handleCanvasSkillTagIconUpload(index, file);
}
event.currentTarget.value = "";
}}
/>
</div>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-md border bg-muted/40">
{tag.iconUrl ? (
<img
src={tag.iconUrl}
alt={tag.name || `标签${index + 1}`}
className="h-full w-full object-cover"
/>
) : (
<TagIcon className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex flex-1 flex-wrap items-center gap-2">
<Select
value={tag.iconName || "message-circle"}
onValueChange={(value) =>
handleCanvasSkillTagChange(index, "iconName", value)
}
>
<SelectTrigger className="h-9 w-[170px]">
<div className="flex items-center gap-2">
<TagIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">开源图标</span>
</div>
</SelectTrigger>
<SelectContent>
{CANVAS_SKILL_ICON_OPTIONS.map((option) => {
const OptionIcon = option.icon;
return (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<OptionIcon className="h-4 w-4 text-muted-foreground" />
<span>{option.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<Input
value={tag.iconUrl || ""}
onChange={(event) =>
handleCanvasSkillTagChange(index, "iconUrl", event.target.value)
}
placeholder="图标 URL(可选)"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
document.getElementById(`canvas-skill-tag-icon-${tag.id}`)?.click()
}
disabled={canvasSkillTagUploadingId === tag.id}
>
{canvasSkillTagUploadingId === tag.id ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : null}
上传图标
</Button>
<input
id={`canvas-skill-tag-icon-${tag.id}`}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void handleCanvasSkillTagIconUpload(index, file);
}
event.currentTarget.value = "";
}}
/>
</div>
</div>
<Input
value={tag.placeholder || ""}
onChange={(event) =>
handleCanvasSkillTagChange(index, "placeholder", event.target.value)
}
placeholder="输入框灰色提示词(可选)"
/>
<Textarea
value={tag.prompt}
onChange={(event) =>
handleCanvasSkillTagChange(index, "prompt", event.target.value)
}
className="min-h-[84px]"
placeholder="请输入该标签对应的预设提示词"
/>
</div>
<Textarea
value={tag.prompt}
onChange={(event) =>
handleCanvasSkillTagChange(index, "prompt", event.target.value)
}
className="min-h-[84px]"
placeholder="请输入该标签对应的预设提示词"
/>
</div>
))}
);
})}
<Button variant="outline" onClick={handleAddCanvasSkillTag} className="w-full">
<Plus className="mr-2 h-4 w-4" /> 添加标签
</Button>

View File

@ -49,6 +49,7 @@ import { buildImageProxyUrl } from "@/lib/image-delivery";
import type { Attachment, ChatMessage } from "@/lib/types";
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
import { normalizeCanvasSkillTags, type CanvasSkillTag } from "@/lib/canvas-skill-tags";
import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
@ -2296,6 +2297,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
() => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null,
[canvasSkillTags, selectedSkillTagId]
);
const SelectedSkillTagIcon = useMemo(
() => getCanvasSkillIconComponent(selectedSkillTag?.iconName),
[selectedSkillTag?.iconName]
);
useEffect(() => {
if (!selectedSkillTagId) return;
@ -5969,6 +5974,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const hasInputOrAttachments = Boolean(
input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag)
);
const composerPlaceholder = isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: selectedSkillTag?.placeholder?.trim() ||
(selectedSkillTag
? `请输入「${selectedSkillTag.name}」相关需求`
: "请输入你的设计需求,例如:绿色椅子 替换 Image");
const detailProductCount = DETAIL_PRODUCT_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
const detailModelReady = DETAIL_MODEL_SLOT_INDEX >= 0 ? Boolean(detailSlots[DETAIL_MODEL_SLOT_INDEX]?.url) : false;
const detailBundleCount = DETAIL_BUNDLE_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
@ -6343,13 +6354,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
)}
{canvasSkillTags.length > 0 && (
<div className="mb-8 rounded-3xl border border-[#eceef2] bg-white/90 p-5">
<div className="mb-4 text-center">
<h3 className="text-[17px] font-semibold text-[#1f2937]">试试这些 Skills</h3>
<div className="mb-8 rounded-3xl border border-[#eceef2] bg-white/90 p-4">
<div className="mb-3 text-center">
<h3 className="text-[16px] font-semibold text-[#1f2937]">试试这些 Skills</h3>
</div>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<div className="grid grid-cols-3 gap-2">
{canvasSkillTags.map((tag) => {
const isActive = selectedSkillTag?.id === tag.id;
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
return (
<button
key={tag.id}
@ -6360,7 +6372,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
editor?.focus();
}}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full border px-3 py-2 text-[15px] font-medium transition-colors",
"inline-flex min-w-0 items-center justify-center gap-1.5 rounded-2xl border px-2 py-1.5 text-[13px] font-medium transition-colors",
isActive
? "border-[#6d7fff] bg-[#eef1ff] text-[#3341a9]"
: "border-[#e4e7ec] bg-white text-[#30343b] hover:border-[#d6dae3] hover:bg-[#fafbfc]"
@ -6371,7 +6383,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<img
src={buildPreviewImageUrl(tag.iconUrl, 48)}
alt={tag.name}
className="h-4 w-4 rounded-sm object-cover"
className="h-3.5 w-3.5 rounded-sm object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
@ -6381,9 +6393,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}}
/>
) : (
<MessageCircle className="h-4 w-4 text-current opacity-70" />
<TagIcon className="h-3.5 w-3.5 text-current opacity-70" />
)}
<span>{tag.name}</span>
<span className="truncate">{tag.name}</span>
</button>
);
})}
@ -7671,7 +7683,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
decoding="async"
/>
) : (
<MessageCircle className="h-4 w-4 text-[#8b5cf6]" />
<SelectedSkillTagIcon className="h-4 w-4 text-[#8b5cf6]" />
)}
<span>{selectedSkillTag.name}</span>
<X className="h-3.5 w-3.5 text-[#9ca3af]" />
@ -7683,11 +7695,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
contentEditable={!isSubmitting}
suppressContentEditableWarning
className="min-h-[40px] max-h-[120px] overflow-y-auto text-sm outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-[#9aa1ad]"
data-placeholder={
isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: "请输入你的设计需求,例如:绿色椅子 替换 Image"
}
data-placeholder={composerPlaceholder}
onInput={(e) => {
// 从 contenteditable 中提取纯文本(排除附件标签文本)
const target = e.target as HTMLDivElement;

View File

@ -0,0 +1,47 @@
"use client";
import type { LucideIcon } from "lucide-react";
import {
BadgePercent,
BookOpen,
Clapperboard,
ImageIcon,
LayoutGrid,
Megaphone,
MessageCircle,
Palette,
ShoppingBag,
Sparkles,
} from "lucide-react";
export type CanvasSkillIconOption = {
value: string;
label: string;
icon: LucideIcon;
};
export const CANVAS_SKILL_ICON_OPTIONS: CanvasSkillIconOption[] = [
{ value: "message-circle", label: "对话气泡", icon: MessageCircle },
{ value: "layout-grid", label: "网格", icon: LayoutGrid },
{ value: "image", label: "图片", icon: ImageIcon },
{ value: "palette", label: "调色盘", icon: Palette },
{ value: "shopping-bag", label: "购物袋", icon: ShoppingBag },
{ value: "megaphone", label: "扩音器", icon: Megaphone },
{ value: "book-open", label: "故事板", icon: BookOpen },
{ value: "clapperboard", label: "场记板", icon: Clapperboard },
{ value: "badge-percent", label: "营销标签", icon: BadgePercent },
{ value: "sparkles", label: "闪光", icon: Sparkles },
];
const ICON_MAP: Record<string, LucideIcon> = CANVAS_SKILL_ICON_OPTIONS.reduce(
(acc, item) => {
acc[item.value] = item.icon;
return acc;
},
{} as Record<string, LucideIcon>
);
export function getCanvasSkillIconComponent(iconName?: string | null): LucideIcon {
const key = String(iconName || "").trim();
return ICON_MAP[key] || MessageCircle;
}

View File

@ -4,6 +4,8 @@ export type CanvasSkillTag = {
id: string;
name: string;
prompt: string;
placeholder?: string;
iconName?: string;
iconUrl?: string;
};
@ -11,36 +13,48 @@ export const DEFAULT_CANVAS_SKILL_TAGS: CanvasSkillTag[] = [
{
id: "social-carousel",
name: "社媒轮播图",
iconName: "layout-grid",
placeholder: "例如:做一组 6 张小红书轮播,主色橙白,突出卖点对比",
prompt:
"你是资深社媒设计师。请输出可直接落地的轮播图方案,包含封面、核心卖点页、场景页与转化页。保持统一视觉风格、品牌色和字体层级,文案精炼且有行动号召。",
},
{
id: "social-media-kit",
name: "社交媒体",
iconName: "image",
placeholder: "例如:给这款产品做 3 套社媒视觉方向,分别偏简约/科技/生活化",
prompt:
"请基于用户需求产出社交媒体视觉方案,优先考虑强识别封面、信息层级清晰和移动端可读性。输出时强调平台适配和可复用模板结构。",
},
{
id: "logo-branding",
name: "Logo 与品牌",
iconName: "palette",
placeholder: "例如:品牌名 AURORA,主打轻奢护肤,设计 3 个 Logo 概念方向",
prompt:
"请以品牌设计顾问视角工作:先定义品牌关键词与调性,再给出 Logo 方向、配色、字体与应用场景建议。风格需统一、可执行,避免空泛描述。",
},
{
id: "storyboard",
name: "分镜故事板",
iconName: "clapperboard",
placeholder: "例如:30 秒产品广告,拆成 8 镜头,强调开箱与使用前后对比",
prompt:
"请先构建分镜叙事结构(开场-冲突-转折-收束),再给出每一镜头的画面构图、主体动作、镜头语言和文案要点。保证镜头连续性和视觉一致性。",
},
{
id: "marketing-brochure",
name: "营销宣传册",
iconName: "megaphone",
placeholder: "例如:做一份 4 页产品宣传册,受众是经销商,风格专业稳重",
prompt:
"请输出可印刷/可投放的宣传册方案,包含封面、核心卖点、案例背书与行动引导。要求信息层级明确、版式节奏均衡、品牌元素统一。",
},
{
id: "ecommerce-main-image",
name: "亚马逊产品套图",
iconName: "shopping-bag",
placeholder: "例如:亚马逊 7 张套图,主图白底,副图展示材质细节和使用场景",
prompt:
"请按电商平台高转化标准输出主图+副图方案,强调产品主体清晰、卖点可视化、场景可信和对比信息明确。保持产品外观一致,不虚构结构细节。",
},
@ -68,6 +82,8 @@ export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] {
const row = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
const name = normalizeString(row.name);
const prompt = normalizeString(row.prompt);
const placeholder = normalizeString(row.placeholder);
const iconName = normalizeString(row.iconName);
const iconUrl = normalizeString(row.iconUrl);
if (!name || !prompt) return;
@ -76,7 +92,14 @@ export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] {
id = `${id}-${index + 1}`;
}
usedIds.add(id);
normalized.push({ id, name, prompt, iconUrl: iconUrl || undefined });
normalized.push({
id,
name,
prompt,
placeholder: placeholder || undefined,
iconName: iconName || undefined,
iconUrl: iconUrl || undefined,
});
});
if (normalized.length === 0) {