feat(canvas): support custom icons for quick skill tags
This commit is contained in:
parent
e93db6b1c7
commit
f0d092cc87
@ -104,6 +104,7 @@ function createEmptyCanvasSkillTag(): CanvasSkillTag {
|
|||||||
id: `tag-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
id: `tag-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
name: "",
|
name: "",
|
||||||
prompt: "",
|
prompt: "",
|
||||||
|
iconUrl: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -998,6 +999,7 @@ export function AdminSettingsClient() {
|
|||||||
const [canvasSkillTags, setCanvasSkillTags] = useState<CanvasSkillTag[]>(
|
const [canvasSkillTags, setCanvasSkillTags] = useState<CanvasSkillTag[]>(
|
||||||
DEFAULT_CANVAS_SKILL_TAGS
|
DEFAULT_CANVAS_SKILL_TAGS
|
||||||
);
|
);
|
||||||
|
const [canvasSkillTagUploadingId, setCanvasSkillTagUploadingId] = useState<string | null>(null);
|
||||||
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
|
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
|
||||||
tags?: CanvasSkillTag[];
|
tags?: CanvasSkillTag[];
|
||||||
}>("/api/canvas-skill-tags", fetcher);
|
}>("/api/canvas-skill-tags", fetcher);
|
||||||
@ -1079,7 +1081,7 @@ export function AdminSettingsClient() {
|
|||||||
|
|
||||||
const handleCanvasSkillTagChange = (
|
const handleCanvasSkillTagChange = (
|
||||||
index: number,
|
index: number,
|
||||||
field: "name" | "prompt",
|
field: "name" | "prompt" | "iconUrl",
|
||||||
value: string
|
value: string
|
||||||
) => {
|
) => {
|
||||||
setCanvasSkillTags((prev) =>
|
setCanvasSkillTags((prev) =>
|
||||||
@ -1089,6 +1091,43 @@ export function AdminSettingsClient() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCanvasSkillTagIconUpload = async (index: number, file: File) => {
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
toast.error("请上传图片文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTag = canvasSkillTags[index];
|
||||||
|
if (!targetTag) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCanvasSkillTagUploadingId(targetTag.id);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const response = await fetch("/api/files/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !result?.url) {
|
||||||
|
throw new Error(result?.error || "上传失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanvasSkillTags((prev) =>
|
||||||
|
prev.map((tag, tagIndex) =>
|
||||||
|
tagIndex === index ? { ...tag, iconUrl: result.url } : tag
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast.success("标签图标上传成功");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.message || "标签图标上传失败");
|
||||||
|
} finally {
|
||||||
|
setCanvasSkillTagUploadingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddCanvasSkillTag = () => {
|
const handleAddCanvasSkillTag = () => {
|
||||||
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
|
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
|
||||||
};
|
};
|
||||||
@ -5698,7 +5737,7 @@ export function AdminSettingsClient() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">画布对话快捷标签</CardTitle>
|
<CardTitle className="text-xl">画布对话快捷标签</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
每个标签包含「标签名 + 预设提示词」。用户点击标签后,发送消息时会自动附带对应提示词。
|
每个标签支持「图标 + 标签名 + 预设提示词」。用户点击标签后,发送消息时会自动附带对应提示词。
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@ -5721,6 +5760,53 @@ export function AdminSettingsClient() {
|
|||||||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
||||||
|
<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>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={tag.prompt}
|
value={tag.prompt}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
|
|||||||
@ -2260,13 +2260,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
};
|
};
|
||||||
summary?: {
|
summary?: {
|
||||||
currentCredits?: number;
|
currentCredits?: number;
|
||||||
totalImages?: number;
|
|
||||||
};
|
};
|
||||||
} | null>("/api/user/membership-summary", async (url: string) => {
|
} | null>("/api/user/membership-summary", async (url: string) => {
|
||||||
const response = await fetch(url, { cache: "no-store" });
|
const response = await fetch(url, { cache: "no-store" });
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
return response.json();
|
return response.json();
|
||||||
});
|
});
|
||||||
|
const { data: userImageStats } = useSWR<{ totalImages?: number } | null>(
|
||||||
|
"/api/user/image-stats",
|
||||||
|
async (url: string) => {
|
||||||
|
const response = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
);
|
||||||
const greetingUserName = useMemo(() => {
|
const greetingUserName = useMemo(() => {
|
||||||
const userName = String(session?.user?.name || "").trim();
|
const userName = String(session?.user?.name || "").trim();
|
||||||
if (userName) return userName;
|
if (userName) return userName;
|
||||||
@ -2279,10 +2286,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
const greetingTitle = greetingUserName
|
const greetingTitle = greetingUserName
|
||||||
? `Hi, ${greetingUserName},我是你的AI设计师`
|
? `Hi, ${greetingUserName},我是你的AI设计师`
|
||||||
: "Hi, 我是你的AI设计师";
|
: "Hi, 我是你的AI设计师";
|
||||||
const totalGeneratedImages = Number(membershipSummary?.summary?.totalImages ?? NaN);
|
const totalGeneratedImages = Number(userImageStats?.totalImages ?? NaN);
|
||||||
const subtitleSuffix = Number.isFinite(totalGeneratedImages)
|
|
||||||
? ` 累计为你生成了 ${totalGeneratedImages.toLocaleString()} 张图。`
|
|
||||||
: "";
|
|
||||||
const aiDetailGenerationEnabled = siteSettings?.aiDetailGenerationEnabled === true;
|
const aiDetailGenerationEnabled = siteSettings?.aiDetailGenerationEnabled === true;
|
||||||
const canvasSkillTags = useMemo(
|
const canvasSkillTags = useMemo(
|
||||||
() => normalizeCanvasSkillTags(canvasSkillTagsData?.tags),
|
() => normalizeCanvasSkillTags(canvasSkillTagsData?.tags),
|
||||||
@ -6302,10 +6306,42 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
<div className={cn(isDetailGenerationMode ? "mb-4" : "mb-8")}>
|
<div className={cn(isDetailGenerationMode ? "mb-4" : "mb-8")}>
|
||||||
<h1 className="text-2xl font-bold mb-2">{greetingTitle}</h1>
|
<h1 className="text-2xl font-bold mb-2">{greetingTitle}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
让我们开始今天的创作吧!{subtitleSuffix}
|
让我们开始今天的创作吧!
|
||||||
|
{Number.isFinite(totalGeneratedImages) ? (
|
||||||
|
<>
|
||||||
|
{" "}累计为你生成了{" "}
|
||||||
|
<span className="text-blue-600 font-semibold">
|
||||||
|
{totalGeneratedImages.toLocaleString()}
|
||||||
|
</span>{" "}
|
||||||
|
张图。
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Banner - 从系统设置中获取 */}
|
||||||
|
{(siteSettings?.bannerEnabled ?? true) && (siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!") && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mb-6 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-300 p-3 rounded-xl text-sm flex items-center justify-between",
|
||||||
|
siteSettings?.bannerLink && "cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (siteSettings?.bannerLink) {
|
||||||
|
window.open(siteSettings.bannerLink, '_blank');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1 bg-blue-100 dark:bg-blue-800 rounded">
|
||||||
|
<Box className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span>{siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!"}</span>
|
||||||
|
</div>
|
||||||
|
<X className="h-4 w-4 cursor-pointer opacity-50 hover:opacity-100" onClick={(e) => e.stopPropagation()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{canvasSkillTags.length > 0 && (
|
{canvasSkillTags.length > 0 && (
|
||||||
<div className="mb-8 rounded-3xl border border-[#eceef2] bg-white/90 p-5">
|
<div className="mb-8 rounded-3xl border border-[#eceef2] bg-white/90 p-5">
|
||||||
<div className="mb-4 text-center">
|
<div className="mb-4 text-center">
|
||||||
@ -6324,14 +6360,30 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
editor?.focus();
|
editor?.focus();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center rounded-full border px-3 py-2 text-[15px] font-medium transition-colors",
|
"inline-flex items-center justify-center gap-2 rounded-full border px-3 py-2 text-[15px] font-medium transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "border-[#6d7fff] bg-[#eef1ff] text-[#3341a9]"
|
? "border-[#6d7fff] bg-[#eef1ff] text-[#3341a9]"
|
||||||
: "border-[#e4e7ec] bg-white text-[#30343b] hover:border-[#d6dae3] hover:bg-[#fafbfc]"
|
: "border-[#e4e7ec] bg-white text-[#30343b] hover:border-[#d6dae3] hover:bg-[#fafbfc]"
|
||||||
)}
|
)}
|
||||||
title={tag.prompt}
|
title={tag.prompt}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.iconUrl ? (
|
||||||
|
<img
|
||||||
|
src={buildPreviewImageUrl(tag.iconUrl, 48)}
|
||||||
|
alt={tag.name}
|
||||||
|
className="h-4 w-4 rounded-sm object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={(event) => {
|
||||||
|
if (event.currentTarget.src !== tag.iconUrl) {
|
||||||
|
event.currentTarget.src = tag.iconUrl || "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MessageCircle className="h-4 w-4 text-current opacity-70" />
|
||||||
|
)}
|
||||||
|
<span>{tag.name}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -6340,29 +6392,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Refresh - Removed */}
|
{/* Refresh - Removed */}
|
||||||
|
|
||||||
{/* Banner - 从系统设置中获取 */}
|
|
||||||
{(siteSettings?.bannerEnabled ?? true) && (siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!") && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-300 p-3 rounded-xl text-sm flex items-center justify-between",
|
|
||||||
siteSettings?.bannerLink && "cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
if (siteSettings?.bannerLink) {
|
|
||||||
window.open(siteSettings.bannerLink, '_blank');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="p-1 bg-blue-100 dark:bg-blue-800 rounded">
|
|
||||||
<Box className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<span>{siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!"}</span>
|
|
||||||
</div>
|
|
||||||
<X className="h-4 w-4 cursor-pointer opacity-50 hover:opacity-100" onClick={(e) => e.stopPropagation()} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -7633,7 +7662,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
onClick={() => setSelectedSkillTagId(null)}
|
onClick={() => setSelectedSkillTagId(null)}
|
||||||
title="点击移除标签"
|
title="点击移除标签"
|
||||||
>
|
>
|
||||||
<MessageCircle className="h-4 w-4 text-[#8b5cf6]" />
|
{selectedSkillTag.iconUrl ? (
|
||||||
|
<img
|
||||||
|
src={buildPreviewImageUrl(selectedSkillTag.iconUrl, 40)}
|
||||||
|
alt={selectedSkillTag.name}
|
||||||
|
className="h-4 w-4 rounded-sm object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MessageCircle className="h-4 w-4 text-[#8b5cf6]" />
|
||||||
|
)}
|
||||||
<span>{selectedSkillTag.name}</span>
|
<span>{selectedSkillTag.name}</span>
|
||||||
<X className="h-3.5 w-3.5 text-[#9ca3af]" />
|
<X className="h-3.5 w-3.5 text-[#9ca3af]" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export type CanvasSkillTag = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
iconUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_CANVAS_SKILL_TAGS: CanvasSkillTag[] = [
|
export const DEFAULT_CANVAS_SKILL_TAGS: CanvasSkillTag[] = [
|
||||||
@ -67,6 +68,7 @@ export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] {
|
|||||||
const row = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
|
const row = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
|
||||||
const name = normalizeString(row.name);
|
const name = normalizeString(row.name);
|
||||||
const prompt = normalizeString(row.prompt);
|
const prompt = normalizeString(row.prompt);
|
||||||
|
const iconUrl = normalizeString(row.iconUrl);
|
||||||
if (!name || !prompt) return;
|
if (!name || !prompt) return;
|
||||||
|
|
||||||
let id = normalizeId(row.id, index);
|
let id = normalizeId(row.id, index);
|
||||||
@ -74,7 +76,7 @@ export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] {
|
|||||||
id = `${id}-${index + 1}`;
|
id = `${id}-${index + 1}`;
|
||||||
}
|
}
|
||||||
usedIds.add(id);
|
usedIds.add(id);
|
||||||
normalized.push({ id, name, prompt });
|
normalized.push({ id, name, prompt, iconUrl: iconUrl || undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (normalized.length === 0) {
|
if (normalized.length === 0) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user