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 { 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 {
DropdownMenu,
@ -816,12 +816,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const getToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
const toolbarHeight = 40;
const titleHeight = 28;
const gap = 10;
let top = rect.top - toolbarHeight - titleHeight - gap;
const toolbarHeight = 48;
const titleSafeHeight = 34;
const gap = 12;
let top = rect.top - toolbarHeight - titleSafeHeight - gap;
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;
@ -1940,7 +1941,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
调尺
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('angle')}>
<Move3D className="h-4 w-4 mr-2" />
<Box className="h-4 w-4 mr-2" />
视角
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('prompt-regenerate')}>
@ -2058,17 +2059,18 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
style={{
top: getToolbarPosition().top,
left: getToolbarPosition().left,
transform: 'translateX(-50%)',
transform: 'translateX(-50%) scale(0.8)',
transformOrigin: 'top center',
pointerEvents: 'auto',
}}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(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" />
<span>快捷编辑</span>
<span className="text-white/55 dark:text-slate-500">Tab</span>
<span className="whitespace-nowrap">快捷编辑</span>
<span className="whitespace-nowrap text-white/55 dark:text-slate-500">Tab</span>
</div>
<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')}
>
<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
@ -2090,17 +2092,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onClick={() => handleAction('rmbg')}
>
<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
@ -2110,7 +2102,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onClick={() => handleAction('watermark')}
>
<Eraser className="mr-1.5 h-4 w-4" />
橡皮工具
去水印
</Button>
<Button
@ -2120,7 +2112,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onClick={() => handleAction('resize')}
>
<Maximize2 className="mr-1.5 h-4 w-4" />
编辑元素
尺寸调整
</Button>
<Button
@ -2130,7 +2122,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onClick={() => handleAction('text-edit')}
>
<Type className="mr-1.5 h-4 w-4" />
编辑文字
文字替换
</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"
onClick={() => handleAction('angle')}
>
<Move3D className="mr-1.5 h-4 w-4" />
多角度
<Box className="mr-1.5 h-4 w-4" />
视角生成
</Button>
<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" />
</Button>
</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')}>
<Expand className="mr-2 h-4 w-4" />
扩图

View File

@ -42,6 +42,20 @@ function normalizeBaseUrl(baseUrl?: string) {
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> {
const contentType = response.headers.get("content-type") || "";
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 url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
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");
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";
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> {
const providers = await getEnabledAiProviders();
const tripoProvider = providers.find((item) => item.type === "tripo-3d");
if (tripoProvider?.apiKey?.trim()) {
const providerApiKey = sanitizeTripoApiKey(String(tripoProvider?.apiKey || ""));
if (tripoProvider && providerApiKey) {
return {
apiKey: tripoProvider.apiKey.trim(),
baseUrl: String(tripoProvider.baseUrl || "").trim() || DEFAULT_TRIPO_BASE_URL,
apiKey: providerApiKey,
baseUrl: sanitizeTripoBaseUrl(String(tripoProvider?.baseUrl || "")) || DEFAULT_TRIPO_BASE_URL,
providerId: tripoProvider.id,
providerName: tripoProvider.name,
};
}
const envApiKey = String(process.env.TRIPO_API_KEY || "").trim();
const envApiKey = sanitizeTripoApiKey(String(process.env.TRIPO_API_KEY || ""));
if (!envApiKey) {
throw new Error("未配置 Tripo3D API,请在系统设置-本地AI新增 Tripo3D 提供商");
}
return {
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,
};
}