feat(canvas): add configurable quick skill tags for chat
This commit is contained in:
parent
278418ff7d
commit
e93db6b1c7
@ -89,11 +89,24 @@ import {
|
||||
normalizeDulupayConfig,
|
||||
type DulupayConfig,
|
||||
} from "@/lib/payment/dulupay-config";
|
||||
import {
|
||||
DEFAULT_CANVAS_SKILL_TAGS,
|
||||
normalizeCanvasSkillTags,
|
||||
type CanvasSkillTag,
|
||||
} from "@/lib/canvas-skill-tags";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
const DEFAULT_RUNWARE_REALTIME_MODEL = "runware:101@1";
|
||||
const DEFAULT_RUNWARE_STYLIZE_MODEL = "runware:106@1";
|
||||
|
||||
function createEmptyCanvasSkillTag(): CanvasSkillTag {
|
||||
return {
|
||||
id: `tag-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: "",
|
||||
prompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
// 类型定义
|
||||
type User = {
|
||||
id: string;
|
||||
@ -982,6 +995,12 @@ export function AdminSettingsClient() {
|
||||
// 快捷短语状态
|
||||
const [questions, setQuestions] = useState<string[]>([]);
|
||||
const { data: questionsData, mutate: mutateQuestions } = useSWR<string[]>("/api/starter-questions", fetcher);
|
||||
const [canvasSkillTags, setCanvasSkillTags] = useState<CanvasSkillTag[]>(
|
||||
DEFAULT_CANVAS_SKILL_TAGS
|
||||
);
|
||||
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
|
||||
tags?: CanvasSkillTag[];
|
||||
}>("/api/canvas-skill-tags", fetcher);
|
||||
|
||||
// 系统通知状态
|
||||
const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string }>({
|
||||
@ -1022,6 +1041,12 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
}, [questionsData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasSkillTagsData) {
|
||||
setCanvasSkillTags(normalizeCanvasSkillTags(canvasSkillTagsData.tags));
|
||||
}
|
||||
}, [canvasSkillTagsData]);
|
||||
|
||||
const handleQuestionChange = (index: number, value: string) => {
|
||||
const newQuestions = [...questions];
|
||||
newQuestions[index] = value;
|
||||
@ -1052,6 +1077,45 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasSkillTagChange = (
|
||||
index: number,
|
||||
field: "name" | "prompt",
|
||||
value: string
|
||||
) => {
|
||||
setCanvasSkillTags((prev) =>
|
||||
prev.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, [field]: value } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddCanvasSkillTag = () => {
|
||||
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
|
||||
};
|
||||
|
||||
const handleRemoveCanvasSkillTag = (index: number) => {
|
||||
setCanvasSkillTags((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
|
||||
};
|
||||
|
||||
const handleSaveCanvasSkillTags = async () => {
|
||||
try {
|
||||
const payload = normalizeCanvasSkillTags(canvasSkillTags);
|
||||
const res = await fetch("/api/canvas-skill-tags", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tags: payload }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setCanvasSkillTags(normalizeCanvasSkillTags(data?.tags ?? payload));
|
||||
toast.success("快捷标签已更新");
|
||||
mutateCanvasSkillTags();
|
||||
} catch (error) {
|
||||
toast.error("快捷标签更新失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 认证设置状态
|
||||
const [authSettings, setAuthSettings] = useState<AuthSettings>({
|
||||
dingTalkAppKey: "",
|
||||
@ -5597,37 +5661,85 @@ export function AdminSettingsClient() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="questions" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">快捷短语</CardTitle>
|
||||
<CardDescription>配置聊天输入框下方的预设快捷提问</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{questions.map((q, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => handleQuestionChange(index, e.target.value)}
|
||||
placeholder={`问题 ${index + 1}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveQuestion(index)}
|
||||
disabled={questions.length <= 1}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" onClick={handleAddQuestion} className="w-full">
|
||||
<Plus className="mr-2 h-4 w-4" /> 添加短语
|
||||
</Button>
|
||||
<Button onClick={handleSaveQuestions} className="w-full md:w-auto">
|
||||
<Save className="mr-2 h-4 w-4" /> 保存设置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">快捷短语</CardTitle>
|
||||
<CardDescription>配置聊天输入框下方的预设快捷提问</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{questions.map((q, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => handleQuestionChange(index, e.target.value)}
|
||||
placeholder={`问题 ${index + 1}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveQuestion(index)}
|
||||
disabled={questions.length <= 1}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" onClick={handleAddQuestion} className="w-full">
|
||||
<Plus className="mr-2 h-4 w-4" /> 添加短语
|
||||
</Button>
|
||||
<Button onClick={handleSaveQuestions} className="w-full md:w-auto">
|
||||
<Save className="mr-2 h-4 w-4" /> 保存设置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<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>
|
||||
<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>
|
||||
<Button onClick={handleSaveCanvasSkillTags} className="w-full md:w-auto">
|
||||
<Save className="mr-2 h-4 w-4" /> 保存标签设置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 认证设置 */}
|
||||
|
||||
69
app/api/canvas-skill-tags/route.ts
Normal file
69
app/api/canvas-skill-tags/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { systemSettings } from "@/lib/db/schema";
|
||||
import {
|
||||
CANVAS_SKILL_TAGS_SETTING_KEY,
|
||||
normalizeCanvasSkillTags,
|
||||
} from "@/lib/canvas-skill-tags";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [setting] = await db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json({
|
||||
tags: normalizeCanvasSkillTags(setting?.value),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CanvasSkillTags API] Get error:", error);
|
||||
return NextResponse.json({
|
||||
tags: normalizeCanvasSkillTags(null),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (session?.user?.type !== "admin") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const rawTags = Array.isArray(body) ? body : body?.tags;
|
||||
const tags = normalizeCanvasSkillTags(rawTags);
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(systemSettings)
|
||||
.set({
|
||||
value: tags,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.where(eq(systemSettings.id, existing.id));
|
||||
} else {
|
||||
await db.insert(systemSettings).values({
|
||||
key: CANVAS_SKILL_TAGS_SETTING_KEY,
|
||||
value: tags,
|
||||
updatedBy: session.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, tags });
|
||||
} catch (error) {
|
||||
console.error("[CanvasSkillTags API] Update error:", error);
|
||||
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,7 @@ import {
|
||||
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 { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
||||
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
|
||||
import { ImageGenerationLoading } from "@/components/image-generation-loading";
|
||||
@ -1440,6 +1441,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
const [activeChatId, setActiveChatId] = useState<string>(chatId);
|
||||
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
|
||||
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
|
||||
const [selectedSkillTagId, setSelectedSkillTagId] = useState<string | null>(null);
|
||||
const [messageViewportKey, setMessageViewportKey] = useState(0);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@ -1647,6 +1649,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
useEffect(() => {
|
||||
setTypedAssistantSections({});
|
||||
setActiveTypingSections({});
|
||||
setSelectedSkillTagId(null);
|
||||
}, [activeChatId]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -2246,6 +2249,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
canvasAutoModeDefaultModelId?: string | null;
|
||||
canvasDetailDefaultModelId?: string | null;
|
||||
}>("/api/site-settings", fetcher);
|
||||
const { data: canvasSkillTagsData } = useSWR<{ tags?: CanvasSkillTag[] }>(
|
||||
"/api/canvas-skill-tags",
|
||||
fetcher
|
||||
);
|
||||
const { data: membershipSummary } = useSWR<{
|
||||
pointsSystemConfig?: {
|
||||
enabled?: boolean;
|
||||
@ -2277,6 +2284,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
? ` 累计为你生成了 ${totalGeneratedImages.toLocaleString()} 张图。`
|
||||
: "";
|
||||
const aiDetailGenerationEnabled = siteSettings?.aiDetailGenerationEnabled === true;
|
||||
const canvasSkillTags = useMemo(
|
||||
() => normalizeCanvasSkillTags(canvasSkillTagsData?.tags),
|
||||
[canvasSkillTagsData?.tags]
|
||||
);
|
||||
const selectedSkillTag = useMemo(
|
||||
() => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null,
|
||||
[canvasSkillTags, selectedSkillTagId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSkillTagId) return;
|
||||
if (!canvasSkillTags.some((tag) => tag.id === selectedSkillTagId)) {
|
||||
setSelectedSkillTagId(null);
|
||||
}
|
||||
}, [canvasSkillTags, selectedSkillTagId]);
|
||||
|
||||
const VIDEO_PROVIDER_TYPES = new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video"]);
|
||||
const visualModels = useMemo(() => {
|
||||
@ -5368,7 +5390,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input.trim() && attachments.length === 0) return;
|
||||
if (!input.trim() && attachments.length === 0 && !selectedSkillTag) return;
|
||||
const effectiveModelId = currentModelIdRef.current || selectedModelId;
|
||||
if (!effectiveModelId) {
|
||||
toast.error("请选择一个模型");
|
||||
@ -5456,9 +5478,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
if (!textContent && effectiveAttachments.length > 0) {
|
||||
textContent = "请处理这张图片";
|
||||
}
|
||||
if (!textContent && selectedSkillTag) {
|
||||
textContent = `请按「${selectedSkillTag.name}」标签的要求处理。`;
|
||||
}
|
||||
|
||||
// 🆕 隐藏提示词(不在 UI 中展示)
|
||||
const hiddenSegments: string[] = [];
|
||||
if (selectedSkillTag?.prompt) {
|
||||
hiddenSegments.push(
|
||||
`【快捷标签:${selectedSkillTag.name}】\n${selectedSkillTag.prompt}`
|
||||
);
|
||||
}
|
||||
if (pickedObjectInfos.length > 0) {
|
||||
const objectsPrompt = pickedObjectInfos.join('\n');
|
||||
hiddenSegments.push(
|
||||
@ -5503,6 +5533,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
model: effectiveModelId,
|
||||
text: textContent,
|
||||
attachments: normalizedAttachmentKeys,
|
||||
skillTagId: selectedSkillTag?.id || null,
|
||||
mode: isVideoModel ? "video" : "image",
|
||||
});
|
||||
|
||||
@ -5548,6 +5579,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
activeChatId,
|
||||
input,
|
||||
attachments,
|
||||
selectedSkillTag,
|
||||
selectedModelId,
|
||||
sendMessage,
|
||||
clearSelection,
|
||||
@ -5930,7 +5962,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
|
||||
const isLoading = status === 'streaming' || status === 'submitted';
|
||||
const isSubmitting = isLoading || isDetailGenerating;
|
||||
const hasInputOrAttachments = Boolean(input.trim() || attachments.length > 0);
|
||||
const hasInputOrAttachments = Boolean(
|
||||
input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag)
|
||||
);
|
||||
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;
|
||||
@ -6272,7 +6306,38 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Suggestions - Removed */}
|
||||
{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>
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||
{canvasSkillTags.map((tag) => {
|
||||
const isActive = selectedSkillTag?.id === tag.id;
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedSkillTagId(tag.id);
|
||||
const editor = textareaRef.current as HTMLDivElement | null;
|
||||
editor?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full border px-3 py-2 text-[15px] font-medium transition-colors",
|
||||
isActive
|
||||
? "border-[#6d7fff] bg-[#eef1ff] text-[#3341a9]"
|
||||
: "border-[#e4e7ec] bg-white text-[#30343b] hover:border-[#d6dae3] hover:bg-[#fafbfc]"
|
||||
)}
|
||||
title={tag.prompt}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refresh - Removed */}
|
||||
|
||||
@ -7560,6 +7625,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
isDetailGenerationMode ? "min-h-[72px]" : "min-h-[60px]"
|
||||
)}
|
||||
>
|
||||
{!isDetailGenerationMode && selectedSkillTag && (
|
||||
<div className="mb-2 flex">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[#f3f4f6] px-3 py-1.5 text-sm font-medium text-[#4b5563] transition-colors hover:bg-[#e9ebef]"
|
||||
onClick={() => setSelectedSkillTagId(null)}
|
||||
title="点击移除标签"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4 text-[#8b5cf6]" />
|
||||
<span>{selectedSkillTag.name}</span>
|
||||
<X className="h-3.5 w-3.5 text-[#9ca3af]" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={textareaRef as any}
|
||||
contentEditable={!isSubmitting}
|
||||
|
||||
85
lib/canvas-skill-tags.ts
Normal file
85
lib/canvas-skill-tags.ts
Normal file
@ -0,0 +1,85 @@
|
||||
export const CANVAS_SKILL_TAGS_SETTING_KEY = "canvas_skill_tags";
|
||||
|
||||
export type CanvasSkillTag = {
|
||||
id: string;
|
||||
name: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_CANVAS_SKILL_TAGS: CanvasSkillTag[] = [
|
||||
{
|
||||
id: "social-carousel",
|
||||
name: "社媒轮播图",
|
||||
prompt:
|
||||
"你是资深社媒设计师。请输出可直接落地的轮播图方案,包含封面、核心卖点页、场景页与转化页。保持统一视觉风格、品牌色和字体层级,文案精炼且有行动号召。",
|
||||
},
|
||||
{
|
||||
id: "social-media-kit",
|
||||
name: "社交媒体",
|
||||
prompt:
|
||||
"请基于用户需求产出社交媒体视觉方案,优先考虑强识别封面、信息层级清晰和移动端可读性。输出时强调平台适配和可复用模板结构。",
|
||||
},
|
||||
{
|
||||
id: "logo-branding",
|
||||
name: "Logo 与品牌",
|
||||
prompt:
|
||||
"请以品牌设计顾问视角工作:先定义品牌关键词与调性,再给出 Logo 方向、配色、字体与应用场景建议。风格需统一、可执行,避免空泛描述。",
|
||||
},
|
||||
{
|
||||
id: "storyboard",
|
||||
name: "分镜故事板",
|
||||
prompt:
|
||||
"请先构建分镜叙事结构(开场-冲突-转折-收束),再给出每一镜头的画面构图、主体动作、镜头语言和文案要点。保证镜头连续性和视觉一致性。",
|
||||
},
|
||||
{
|
||||
id: "marketing-brochure",
|
||||
name: "营销宣传册",
|
||||
prompt:
|
||||
"请输出可印刷/可投放的宣传册方案,包含封面、核心卖点、案例背书与行动引导。要求信息层级明确、版式节奏均衡、品牌元素统一。",
|
||||
},
|
||||
{
|
||||
id: "ecommerce-main-image",
|
||||
name: "亚马逊产品套图",
|
||||
prompt:
|
||||
"请按电商平台高转化标准输出主图+副图方案,强调产品主体清晰、卖点可视化、场景可信和对比信息明确。保持产品外观一致,不虚构结构细节。",
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeString(value: unknown) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function normalizeId(value: unknown, fallbackIndex: number) {
|
||||
const raw = normalizeString(value).toLowerCase();
|
||||
const id = raw
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
return id || `tag-${fallbackIndex + 1}`;
|
||||
}
|
||||
|
||||
export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] {
|
||||
const source = Array.isArray(value) ? value : DEFAULT_CANVAS_SKILL_TAGS;
|
||||
const usedIds = new Set<string>();
|
||||
const normalized: CanvasSkillTag[] = [];
|
||||
|
||||
source.forEach((item, index) => {
|
||||
const row = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
|
||||
const name = normalizeString(row.name);
|
||||
const prompt = normalizeString(row.prompt);
|
||||
if (!name || !prompt) return;
|
||||
|
||||
let id = normalizeId(row.id, index);
|
||||
while (usedIds.has(id)) {
|
||||
id = `${id}-${index + 1}`;
|
||||
}
|
||||
usedIds.add(id);
|
||||
normalized.push({ id, name, prompt });
|
||||
});
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return DEFAULT_CANVAS_SKILL_TAGS;
|
||||
}
|
||||
|
||||
return normalized.slice(0, 30);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user