feat(canvas): add icon picker and compact skill tags
This commit is contained in:
parent
f0d092cc87
commit
94b27f25d5
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
47
components/canvas/canvas-skill-icons.tsx
Normal file
47
components/canvas/canvas-skill-icons.tsx
Normal 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;
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user