fix(canvas): refine toolbar labels/style and harden Tripo API key handling
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-05 10:13:19 +08:00
parent 4349c1cd5f
commit c1dee85366
3 changed files with 77 additions and 36 deletions

View File

@ -2,7 +2,7 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Move3D, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote } from "lucide-react"; import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Box, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@ -816,12 +816,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const getToolbarPosition = () => { const getToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") { if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const toolbarHeight = 40; const toolbarHeight = 48;
const titleHeight = 28; const titleSafeHeight = 34;
const gap = 10; const gap = 12;
let top = rect.top - toolbarHeight - titleHeight - gap; let top = rect.top - toolbarHeight - titleSafeHeight - gap;
if (top < 12) { if (top < 12) {
top = rect.top + 8; const maxTop = Math.max(12, window.innerHeight - toolbarHeight - 12);
top = Math.min(rect.bottom + 10, maxTop);
} }
const minLeft = 80; const minLeft = 80;
@ -1940,7 +1941,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
调尺 调尺
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('angle')}> <DropdownMenuItem onClick={() => handleAction('angle')}>
<Move3D className="h-4 w-4 mr-2" /> <Box className="h-4 w-4 mr-2" />
视角 视角
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('prompt-regenerate')}> <DropdownMenuItem onClick={() => handleAction('prompt-regenerate')}>
@ -2058,17 +2059,18 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
style={{ style={{
top: getToolbarPosition().top, top: getToolbarPosition().top,
left: getToolbarPosition().left, left: getToolbarPosition().left,
transform: 'translateX(-50%)', transform: 'translateX(-50%) scale(0.8)',
transformOrigin: 'top center',
pointerEvents: 'auto', pointerEvents: 'auto',
}} }}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="mr-1 inline-flex h-10 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-[13px] font-semibold text-white dark:bg-slate-100 dark:text-slate-900"> <div className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 text-[13px] font-semibold text-white dark:bg-slate-100 dark:text-slate-900">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
<span>快捷编辑</span> <span className="whitespace-nowrap">快捷编辑</span>
<span className="text-white/55 dark:text-slate-500">Tab</span> <span className="whitespace-nowrap text-white/55 dark:text-slate-500">Tab</span>
</div> </div>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" /> <div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
@ -2080,7 +2082,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onClick={() => handleAction('enhance')} onClick={() => handleAction('enhance')}
> >
<span className="mr-1.5 rounded-md border border-slate-300 px-1.5 text-[11px] leading-5 text-slate-600 dark:border-slate-500 dark:text-slate-100">HD</span> <span className="mr-1.5 rounded-md border border-slate-300 px-1.5 text-[11px] leading-5 text-slate-600 dark:border-slate-500 dark:text-slate-100">HD</span>
放大 高清增强
</Button> </Button>
<Button <Button
@ -2090,17 +2092,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onClick={() => handleAction('rmbg')} onClick={() => handleAction('rmbg')}
> >
<Scissors className="mr-1.5 h-4 w-4" /> <Scissors className="mr-1.5 h-4 w-4" />
去背景 抠图
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('scene-fusion')}
>
<Sparkles className="mr-1.5 h-4 w-4" />
Mockup
</Button> </Button>
<Button <Button
@ -2110,7 +2102,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onClick={() => handleAction('watermark')} onClick={() => handleAction('watermark')}
> >
<Eraser className="mr-1.5 h-4 w-4" /> <Eraser className="mr-1.5 h-4 w-4" />
橡皮工具 去水印
</Button> </Button>
<Button <Button
@ -2120,7 +2112,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onClick={() => handleAction('resize')} onClick={() => handleAction('resize')}
> >
<Maximize2 className="mr-1.5 h-4 w-4" /> <Maximize2 className="mr-1.5 h-4 w-4" />
编辑元素 尺寸调整
</Button> </Button>
<Button <Button
@ -2130,7 +2122,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onClick={() => handleAction('text-edit')} onClick={() => handleAction('text-edit')}
> >
<Type className="mr-1.5 h-4 w-4" /> <Type className="mr-1.5 h-4 w-4" />
编辑文字 文字替换
</Button> </Button>
<Button <Button
@ -2139,8 +2131,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10" className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('angle')} onClick={() => handleAction('angle')}
> >
<Move3D className="mr-1.5 h-4 w-4" /> <Box className="mr-1.5 h-4 w-4" />
多角度 视角生成
</Button> </Button>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" /> <div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
@ -2156,7 +2148,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="center" side="bottom"> <DropdownMenuContent
align="center"
side="bottom"
className="bg-white opacity-100 backdrop-blur-none dark:bg-slate-900 dark:text-slate-100"
>
<DropdownMenuItem onClick={() => handleAction('extend')}> <DropdownMenuItem onClick={() => handleAction('extend')}>
<Expand className="mr-2 h-4 w-4" /> <Expand className="mr-2 h-4 w-4" />
扩图 扩图

View File

@ -42,6 +42,20 @@ function normalizeBaseUrl(baseUrl?: string) {
return resolved.replace(/\/+$/, ""); return resolved.replace(/\/+$/, "");
} }
function sanitizeHeaderText(value: string): string {
return String(value || "")
.replace(/[\u2028\u2029]/g, "")
.replace(/[\u0000-\u001F\u007F]/g, "")
.trim();
}
function hasNonByteStringChar(value: string): boolean {
for (let i = 0; i < value.length; i += 1) {
if (value.charCodeAt(i) > 255) return true;
}
return false;
}
async function parseResponseBody(response: Response): Promise<unknown> { async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") || ""; const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
@ -59,7 +73,23 @@ export async function tripoRequest<T = unknown>(
const path = options.path.startsWith("/") ? options.path : `/${options.path}`; const path = options.path.startsWith("/") ? options.path : `/${options.path}`;
const url = `${normalizeBaseUrl(config.baseUrl)}${path}`; const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
const headers = new Headers(options.headers || {}); const headers = new Headers(options.headers || {});
headers.set("Authorization", `Bearer ${config.apiKey}`);
const normalizedApiKey = sanitizeHeaderText(String(config.apiKey || "")).replace(/\s+/g, "");
if (!normalizedApiKey) {
throw new TripoApiError({
message: "Tripo3D API Key 为空,请在本地AI设置中重新保存",
httpStatus: 400,
});
}
if (hasNonByteStringChar(normalizedApiKey)) {
throw new TripoApiError({
message:
"Tripo3D API Key 含非法字符(可能是复制时带入的隐藏换行符),请在本地AI设置中重新粘贴保存",
httpStatus: 400,
});
}
headers.set("Authorization", `Bearer ${normalizedApiKey}`);
headers.set("Accept", "application/json"); headers.set("Accept", "application/json");
let body: BodyInit | undefined; let body: BodyInit | undefined;

View File

@ -5,27 +5,42 @@ import type { TripoProviderConfig } from "./types";
const DEFAULT_TRIPO_BASE_URL = "https://api.tripo3d.ai/v2/openapi"; const DEFAULT_TRIPO_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
function sanitizeTripoApiKey(value: string): string {
return String(value || "")
.replace(/[\u2028\u2029]/g, "")
.replace(/[\u0000-\u001F\u007F]/g, "")
.replace(/\s+/g, "")
.trim();
}
function sanitizeTripoBaseUrl(value: string): string {
return String(value || "")
.replace(/[\u2028\u2029]/g, "")
.replace(/[\u0000-\u001F\u007F]/g, "")
.trim();
}
export async function getTripoProviderConfig(): Promise<TripoProviderConfig> { export async function getTripoProviderConfig(): Promise<TripoProviderConfig> {
const providers = await getEnabledAiProviders(); const providers = await getEnabledAiProviders();
const tripoProvider = providers.find((item) => item.type === "tripo-3d"); const tripoProvider = providers.find((item) => item.type === "tripo-3d");
if (tripoProvider?.apiKey?.trim()) { const providerApiKey = sanitizeTripoApiKey(String(tripoProvider?.apiKey || ""));
if (tripoProvider && providerApiKey) {
return { return {
apiKey: tripoProvider.apiKey.trim(), apiKey: providerApiKey,
baseUrl: String(tripoProvider.baseUrl || "").trim() || DEFAULT_TRIPO_BASE_URL, baseUrl: sanitizeTripoBaseUrl(String(tripoProvider?.baseUrl || "")) || DEFAULT_TRIPO_BASE_URL,
providerId: tripoProvider.id, providerId: tripoProvider.id,
providerName: tripoProvider.name, providerName: tripoProvider.name,
}; };
} }
const envApiKey = String(process.env.TRIPO_API_KEY || "").trim(); const envApiKey = sanitizeTripoApiKey(String(process.env.TRIPO_API_KEY || ""));
if (!envApiKey) { if (!envApiKey) {
throw new Error("未配置 Tripo3D API,请在系统设置-本地AI新增 Tripo3D 提供商"); throw new Error("未配置 Tripo3D API,请在系统设置-本地AI新增 Tripo3D 提供商");
} }
return { return {
apiKey: envApiKey, apiKey: envApiKey,
baseUrl: String(process.env.TRIPO_BASE_URL || "").trim() || DEFAULT_TRIPO_BASE_URL, baseUrl: sanitizeTripoBaseUrl(String(process.env.TRIPO_BASE_URL || "")) || DEFAULT_TRIPO_BASE_URL,
}; };
} }