Your Name bfcb777ba4
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: persist 3d assets and add configurable 3d points billing
2026-03-06 12:16:31 +08:00

2152 lines
73 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import {
ArrowLeft,
Bone,
Box,
Boxes,
ChevronDown,
ChevronUp,
Download,
Eye,
EyeOff,
Grid3X3,
History,
Home,
ImagePlus,
Images,
Layers3,
LayoutGrid,
Loader2,
Maximize,
Package,
Palette,
PenLine,
Play,
PlusCircle,
RefreshCw,
RotateCw,
Scissors,
Settings2,
Sparkles,
Sun,
WandSparkles,
X,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import {
type Model3DLayer,
type Model3DStats,
Model3DViewer,
type Model3DViewerSettings,
} from "@/components/canvas/model3d/Model3DViewer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn, fetcher } from "@/lib/utils";
import {
canvasService,
type Model3DTaskStatus,
model3DGenerationService,
} from "@/services/canvas";
import { canvasSelectors, useCanvasStore } from "@/store/canvas";
type GenerationMode = "text" | "image" | "multiview" | "batch";
type TaskMode =
| GenerationMode
| "convert"
| "refine"
| "texture"
| "stylize"
| "rigcheck"
| "rig"
| "retarget"
| "segment"
| "complete"
| "lowpoly";
type TaskRecord = {
taskId: string;
status: Model3DTaskStatus;
mode: TaskMode;
prompt: string;
modelVersion: string;
createdAt: number;
progress?: number | null;
modelUrl?: string | null;
previewImageUrl?: string | null;
error?: string | null;
sourceTaskId?: string | null;
exportFormat?: string | null;
};
type TaskRecordApiEntity = {
taskId?: unknown;
status?: unknown;
mode?: unknown;
prompt?: unknown;
modelVersion?: unknown;
progress?: unknown;
modelUrl?: unknown;
previewImageUrl?: unknown;
error?: unknown;
sourceTaskId?: unknown;
exportFormat?: unknown;
createdAt?: unknown;
};
type MultiViewSlot = {
id: string;
file: File | null;
previewUrl: string | null;
};
const MODEL_VERSION_OPTIONS = [
"Turbo-v1.0-20250506",
"v3.1-20260211",
"v3.0-20250812",
"v2.5-20250123",
"v2.0-20240919",
"v1.4-20240625",
] as const;
const ENV_PRESET_OPTIONS: Model3DViewerSettings["envPreset"][] = [
"city",
"sunset",
"studio",
"dawn",
"night",
"warehouse",
"forest",
"apartment",
"park",
"lobby",
];
const EXPORT_FORMAT_OPTIONS = [
"GLTF",
"USDZ",
"FBX",
"OBJ",
"STL",
"3MF",
] as const;
const STYLE_OPTIONS = ["lego", "voxel", "voronoi", "minecraft"] as const;
const RIG_TYPE_OPTIONS = [
"biped",
"quadruped",
"hexapod",
"octopod",
"avian",
"serpentine",
"aquatic",
"others",
] as const;
const RIG_SPEC_OPTIONS = ["tripo", "mixamo"] as const;
const RIG_FORMAT_OPTIONS = ["glb", "fbx"] as const;
const ANIMATION_OPTIONS = [
"preset:idle",
"preset:walk",
"preset:run",
"preset:jump",
"preset:turn",
"preset:slash",
"preset:shoot",
"preset:hurt",
"preset:fall",
] as const;
const FINAL_TASK_STATUS = new Set<Model3DTaskStatus>([
"success",
"failed",
"banned",
"expired",
"cancelled",
"unknown",
]);
const TASK_MODE_SET = new Set<TaskMode>([
"text",
"image",
"multiview",
"batch",
"convert",
"refine",
"texture",
"stylize",
"rigcheck",
"rig",
"retarget",
"segment",
"complete",
"lowpoly",
]);
const TASK_HISTORY_LIMIT = 40;
const TASK_HISTORY_STORAGE_PREFIX = "canvas-model3d-task-history-v2";
function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus {
switch (status) {
case "queued":
case "running":
case "success":
case "failed":
case "banned":
case "expired":
case "cancelled":
case "unknown":
return status;
default:
return "unknown";
}
}
function extractModelFormat(modelUrl?: string | null): string {
const value = String(modelUrl || "").toLowerCase();
if (!value) return "3d";
if (value.includes(".gltf")) return "gltf";
if (value.includes(".glb")) return "glb";
if (value.includes(".usdz")) return "usdz";
if (value.includes(".fbx")) return "fbx";
if (value.includes(".obj")) return "obj";
if (value.includes(".stl")) return "stl";
if (value.includes(".3mf")) return "3mf";
return "3d";
}
function createMultiViewSlots(count = 4): MultiViewSlot[] {
return Array.from({ length: count }, (_, index) => ({
id: `slot_${index + 1}`,
file: null,
previewUrl: null,
}));
}
function createBatchImageSlots(count = 10): MultiViewSlot[] {
return Array.from({ length: count }, (_, index) => ({
id: `batch_slot_${index + 1}`,
file: null,
previewUrl: null,
}));
}
function modeLabel(mode: TaskMode) {
switch (mode) {
case "text":
return "文生3D";
case "image":
return "图生3D";
case "multiview":
return "多视图3D";
case "batch":
return "批量图生3D";
case "convert":
return "格式转换";
case "refine":
return "模型精修";
case "texture":
return "纹理重绘";
case "stylize":
return "风格化";
case "rigcheck":
return "可绑定检测";
case "rig":
return "自动绑定";
case "retarget":
return "动作重定向";
case "segment":
return "网格分割";
case "complete":
return "网格补全";
case "lowpoly":
return "低模优化";
default:
return mode;
}
}
function statusLabel(status: Model3DTaskStatus) {
switch (status) {
case "queued":
return "排队中";
case "running":
return "生成中";
case "success":
return "成功";
case "failed":
return "失败";
case "banned":
return "审核拦截";
case "expired":
return "已过期";
case "cancelled":
return "已取消";
default:
return "未知";
}
}
function makeStorageKey(projectId: string) {
return `${TASK_HISTORY_STORAGE_PREFIX}:${projectId}`;
}
function normalizeTaskMode(mode: unknown): TaskMode {
const value = String(mode || "").trim() as TaskMode;
return TASK_MODE_SET.has(value) ? value : "text";
}
function toTaskTimestamp(value: unknown): number {
if (value instanceof Date) return value.getTime();
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return Date.now();
}
function normalizeTaskRecord(raw: TaskRecord): TaskRecord {
return {
...raw,
taskId: String(raw.taskId || "").trim(),
status: normalizeTaskStatus(raw.status),
mode: normalizeTaskMode(raw.mode),
prompt: String(raw.prompt || "").trim(),
modelVersion: String(raw.modelVersion || "").trim(),
createdAt: toTaskTimestamp(raw.createdAt),
progress:
raw.progress === undefined || raw.progress === null
? null
: Number.isFinite(Number(raw.progress))
? Number(raw.progress)
: null,
modelUrl: raw.modelUrl ? String(raw.modelUrl).trim() : null,
previewImageUrl: raw.previewImageUrl
? String(raw.previewImageUrl).trim()
: null,
error: raw.error ? String(raw.error).trim() : null,
sourceTaskId: raw.sourceTaskId ? String(raw.sourceTaskId).trim() : null,
exportFormat: raw.exportFormat ? String(raw.exportFormat).trim() : null,
};
}
function mapApiEntityToTaskRecord(entity: TaskRecordApiEntity): TaskRecord | null {
const taskId = String(entity?.taskId || "").trim();
if (!taskId) return null;
return normalizeTaskRecord({
taskId,
status: normalizeTaskStatus(String(entity?.status || "")),
mode: normalizeTaskMode(entity?.mode),
prompt: String(entity?.prompt || "").trim(),
modelVersion: String(entity?.modelVersion || "").trim(),
createdAt: toTaskTimestamp(entity?.createdAt),
progress:
entity?.progress === undefined || entity?.progress === null
? null
: Number(entity.progress),
modelUrl: entity?.modelUrl ? String(entity.modelUrl).trim() : null,
previewImageUrl: entity?.previewImageUrl
? String(entity.previewImageUrl).trim()
: null,
error: entity?.error ? String(entity.error).trim() : null,
sourceTaskId: entity?.sourceTaskId ? String(entity.sourceTaskId).trim() : null,
exportFormat: entity?.exportFormat ? String(entity.exportFormat).trim() : null,
});
}
function taskRecordChanged(prev: TaskRecord, next: TaskRecord) {
return (
prev.status !== next.status ||
prev.progress !== next.progress ||
prev.modelUrl !== next.modelUrl ||
prev.previewImageUrl !== next.previewImageUrl ||
prev.error !== next.error ||
prev.prompt !== next.prompt ||
prev.modelVersion !== next.modelVersion ||
prev.sourceTaskId !== next.sourceTaskId ||
prev.exportFormat !== next.exportFormat
);
}
export default function CanvasModel3DPage() {
const router = useRouter();
const routeParams = useParams();
const id = String(routeParams?.id || "");
const legacyHistoryStorageKey = useMemo(() => makeStorageKey(id), [id]);
const { data: session } = useSession();
const user = session?.user;
const viewport = useCanvasStore(canvasSelectors.viewport);
const addNode = useCanvasStore((state) => state.addNode);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const [mode, setMode] = useState<GenerationMode>("text");
const [prompt, setPrompt] = useState("");
const [negativePrompt, setNegativePrompt] = useState("");
const [modelVersion, setModelVersion] = useState<
(typeof MODEL_VERSION_OPTIONS)[number]
>("Turbo-v1.0-20250506");
const [faceLimit, setFaceLimit] = useState(120_000);
const [texture, setTexture] = useState(true);
const [pbr, setPbr] = useState(true);
const [autoSize, setAutoSize] = useState(false);
const [quad, setQuad] = useState(false);
const [generateParts, setGenerateParts] = useState(false);
const [smartLowPoly, setSmartLowPoly] = useState(false);
const [geometryQuality, setGeometryQuality] = useState<
"standard" | "detailed"
>("standard");
const [textureQuality, setTextureQuality] = useState<"standard" | "detailed">(
"standard"
);
const [textureAlignment, setTextureAlignment] = useState<
"original_image" | "geometry"
>("original_image");
const [orientation, setOrientation] = useState<"default" | "align_image">(
"default"
);
const [modelSeed, setModelSeed] = useState("");
const [textureSeed, setTextureSeed] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
const [referenceImage, setReferenceImage] = useState<File | null>(null);
const [referencePreviewUrl, setReferencePreviewUrl] = useState<string | null>(
null
);
const [multiViewSlots, setMultiViewSlots] = useState<MultiViewSlot[]>(() =>
createMultiViewSlots()
);
const [batchImageSlots, setBatchImageSlots] = useState<MultiViewSlot[]>(() =>
createBatchImageSlots()
);
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
null
);
const [advancedOpen, setAdvancedOpen] = useState(false);
// Viewer Settings
const [viewerSettings, setViewerSettings] = useState<Model3DViewerSettings>({
envPreset: "city",
lightIntensity: 1,
autoRotate: false,
showGrid: true,
wireframe: false,
});
const [viewerStats, setViewerStats] = useState<Model3DStats>({
triangles: 0,
vertices: 0,
meshCount: 0,
});
const [viewerLayers, setViewerLayers] = useState<Model3DLayer[]>([]);
const [layerVisibility, setLayerVisibility] = useState<
Record<string, boolean>
>({});
const [isolatedLayerId, setIsolatedLayerId] = useState<string | null>(null);
// Post Processing States
const [exportFormat, setExportFormat] =
useState<(typeof EXPORT_FORMAT_OPTIONS)[number]>("GLTF");
const [stylizeStyle, setStylizeStyle] =
useState<(typeof STYLE_OPTIONS)[number]>("lego");
const [stylizeBlockSize, setStylizeBlockSize] = useState(80);
const [texturePromptText, setTexturePromptText] = useState("");
const [rigType, setRigType] =
useState<(typeof RIG_TYPE_OPTIONS)[number]>("biped");
const [rigSpec, setRigSpec] =
useState<(typeof RIG_SPEC_OPTIONS)[number]>("tripo");
const [rigFormat, setRigFormat] =
useState<(typeof RIG_FORMAT_OPTIONS)[number]>("glb");
const [retargetAnimation, setRetargetAnimation] =
useState<(typeof ANIMATION_OPTIONS)[number]>("preset:walk");
const [retargetBake, setRetargetBake] = useState(true);
const [retargetExportWithGeometry, setRetargetExportWithGeometry] =
useState(false);
const [retargetInPlace, setRetargetInPlace] = useState(false);
const [completionPartNames, setCompletionPartNames] = useState("");
const [lowpolyFaceLimit, setLowpolyFaceLimit] = useState(30_000);
const [lowpolyQuad, setLowpolyQuad] = useState(false);
const [lowpolyBake, setLowpolyBake] = useState(true);
// UI States
const [activeTab, setActiveTab] = useState<"create" | "edit" | "scene">(
"create"
);
const pollAttemptsRef = useRef(0);
const multiViewSlotsRef = useRef<MultiViewSlot[]>([]);
const batchImageSlotsRef = useRef<MultiViewSlot[]>([]);
// Fetch site settings for Logo
const { data: siteSettingsData } = useSWR("/api/site-settings", fetcher);
const { data: membershipSummary } = useSWR<{
summary?: {
currentCredits?: number;
};
} | null>("/api/user/membership-summary", async (url: string) => {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) return null;
return response.json();
});
const siteLogo = siteSettingsData?.siteLogo;
const siteLogoDark = siteSettingsData?.siteLogoDark;
const siteName = siteSettingsData?.siteName || "SparkAI";
const pointsLabel = useMemo(() => {
const rawValue = Number(membershipSummary?.summary?.currentCredits ?? 0);
const normalized = Number.isFinite(rawValue) ? Math.max(0, rawValue) : 0;
return normalized.toLocaleString("en-US");
}, [membershipSummary?.summary?.currentCredits]);
const userAvatarFallback = useMemo(() => {
const fromName = String(user?.name || "").trim();
if (fromName) return fromName.charAt(0).toUpperCase();
const fromEmail = String(user?.email || "").trim();
if (fromEmail) return fromEmail.charAt(0).toUpperCase();
return "U";
}, [user?.email, user?.name]);
// Display logo logic (prefer dark mode logo since we are in dark theme)
const displayLogo =
siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo;
// Effect hooks for data loading and cleanup
useEffect(() => {
console.log("CanvasModel3DPage mounted - v3.0 Dark Mode");
}, []);
useEffect(() => {
const html = document.documentElement;
const body = document.body;
const prevHtmlOverscrollX = html.style.overscrollBehaviorX;
const prevBodyOverscrollX = body.style.overscrollBehaviorX;
html.style.overscrollBehaviorX = "none";
body.style.overscrollBehaviorX = "none";
const preventHistorySwipe = (event: WheelEvent) => {
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
if (event.cancelable) {
event.preventDefault();
}
};
window.addEventListener("wheel", preventHistorySwipe, { passive: false });
return () => {
window.removeEventListener("wheel", preventHistorySwipe);
html.style.overscrollBehaviorX = prevHtmlOverscrollX;
body.style.overscrollBehaviorX = prevBodyOverscrollX;
};
}, []);
const persistTaskRecord = useCallback(
async (task: TaskRecord) => {
if (!id) return;
try {
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
asset: {
taskId: task.taskId,
status: task.status,
mode: task.mode,
prompt: task.prompt,
modelVersion: task.modelVersion,
progress: task.progress ?? null,
modelUrl: task.modelUrl ?? null,
previewImageUrl: task.previewImageUrl ?? null,
error: task.error ?? null,
sourceTaskId: task.sourceTaskId ?? null,
exportFormat: task.exportFormat ?? null,
createdAt: task.createdAt,
},
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload?.error || `HTTP ${response.status}`);
}
} catch (error) {
console.error("[Model3DPage] 持久化 3D 资产失败:", error);
}
},
[id]
);
useEffect(() => {
if (!id) return;
let cancelled = false;
const migrateLegacyHistory = async () => {
if (typeof window === "undefined") return [] as TaskRecord[];
const raw = window.localStorage.getItem(legacyHistoryStorageKey);
if (!raw) return [] as TaskRecord[];
try {
const parsed = JSON.parse(raw) as TaskRecord[];
if (!Array.isArray(parsed)) {
window.localStorage.removeItem(legacyHistoryStorageKey);
return [] as TaskRecord[];
}
const normalized = parsed
.filter((item) => item && typeof item.taskId === "string")
.map((item) => normalizeTaskRecord(item))
.slice(0, TASK_HISTORY_LIMIT);
for (const item of normalized) {
await persistTaskRecord(item);
}
window.localStorage.removeItem(legacyHistoryStorageKey);
return normalized;
} catch {
window.localStorage.removeItem(legacyHistoryStorageKey);
return [] as TaskRecord[];
}
};
const loadTaskHistory = async () => {
try {
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
cache: "no-store",
});
const payload = (await response.json().catch(() => ({}))) as {
success?: boolean;
error?: string;
data?: TaskRecordApiEntity[];
};
if (!response.ok || payload?.success === false) {
throw new Error(payload?.error || "加载 3D 资产失败");
}
const list: TaskRecordApiEntity[] = Array.isArray(payload?.data)
? payload.data
: [];
let normalized = list
.map((item) => mapApiEntityToTaskRecord(item))
.filter((item): item is TaskRecord => Boolean(item))
.slice(0, TASK_HISTORY_LIMIT);
if (normalized.length === 0) {
normalized = await migrateLegacyHistory();
} else if (typeof window !== "undefined") {
window.localStorage.removeItem(legacyHistoryStorageKey);
}
if (cancelled) return;
setTaskHistory(normalized);
const pending = normalized.find(
(item) => !FINAL_TASK_STATUS.has(item.status)
);
setCurrentTask(pending || normalized[0] || null);
} catch (error) {
console.error("[Model3DPage] 加载 3D 资产库失败:", error);
const migrated = await migrateLegacyHistory();
if (cancelled) return;
setTaskHistory(migrated);
const pending = migrated.find(
(item) => !FINAL_TASK_STATUS.has(item.status)
);
setCurrentTask(pending || migrated[0] || null);
}
};
void loadTaskHistory();
return () => {
cancelled = true;
};
}, [id, legacyHistoryStorageKey, persistTaskRecord]);
useEffect(() => {
if (!referenceImage) {
if (referencePreviewUrl) {
URL.revokeObjectURL(referencePreviewUrl);
}
setReferencePreviewUrl(null);
return;
}
const objectUrl = URL.createObjectURL(referenceImage);
setReferencePreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return objectUrl;
});
return () => {
URL.revokeObjectURL(objectUrl);
};
}, [referenceImage]);
useEffect(() => {
multiViewSlotsRef.current = multiViewSlots;
}, [multiViewSlots]);
useEffect(() => {
batchImageSlotsRef.current = batchImageSlots;
}, [batchImageSlots]);
useEffect(() => {
return () => {
for (const slot of multiViewSlotsRef.current) {
if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl);
}
for (const slot of batchImageSlotsRef.current) {
if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl);
}
};
}, []);
const upsertTaskHistory = useCallback(
(next: TaskRecord, options?: { persist?: boolean }) => {
const normalized = normalizeTaskRecord(next);
setTaskHistory((prev) => {
const index = prev.findIndex((item) => item.taskId === normalized.taskId);
if (index >= 0) {
const updated = [...prev];
updated[index] = { ...updated[index], ...normalized };
return updated.slice(0, TASK_HISTORY_LIMIT);
}
return [normalized, ...prev].slice(0, TASK_HISTORY_LIMIT);
});
if (options?.persist !== false) {
void persistTaskRecord(normalized);
}
},
[persistTaskRecord]
);
const effectiveLayerVisibility = useMemo(() => {
if (!isolatedLayerId) return layerVisibility;
const next: Record<string, boolean> = {};
for (const layer of viewerLayers) {
next[layer.id] =
layer.id === isolatedLayerId && layerVisibility[layer.id] !== false;
}
return next;
}, [isolatedLayerId, layerVisibility, viewerLayers]);
const currentModelUrl = currentTask?.modelUrl || null;
const currentPreviewImage =
currentTask?.previewImageUrl ||
(mode === "image" ? referencePreviewUrl : null) ||
(mode === "multiview"
? multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl
: null) ||
(mode === "batch"
? batchImageSlots.find((slot) => slot.previewUrl)?.previewUrl
: null) ||
null;
// Polling logic
useEffect(() => {
if (!currentTask || FINAL_TASK_STATUS.has(currentTask.status)) {
return;
}
pollAttemptsRef.current = 0;
let cancelled = false;
const runPoll = async () => {
if (cancelled) return;
pollAttemptsRef.current += 1;
const result = await model3DGenerationService.getTask(currentTask.taskId);
if (!result.success || !result.data) {
const failMessage = result.error || "任务查询失败";
const failedRecord: TaskRecord = {
...currentTask,
status: "failed",
error: failMessage,
};
setCurrentTask(failedRecord);
upsertTaskHistory(failedRecord);
toast.error(failMessage);
return;
}
const status = normalizeTaskStatus(result.data.status);
const nextRecord: TaskRecord = {
...currentTask,
status,
progress: result.data.progress ?? null,
modelUrl: result.data.modelUrl ?? currentTask.modelUrl ?? null,
previewImageUrl:
result.data.previewImageUrl ?? currentTask.previewImageUrl ?? null,
error:
status === "failed" ? result.data.errorMessage || "任务失败" : null,
};
if (taskRecordChanged(currentTask, nextRecord)) {
setCurrentTask(nextRecord);
upsertTaskHistory(nextRecord);
}
if (status === "success") {
toast.success(`${modeLabel(nextRecord.mode)}任务已完成`);
if (
autoDownloadTaskId &&
autoDownloadTaskId === nextRecord.taskId &&
nextRecord.modelUrl
) {
const link = document.createElement("a");
link.href = nextRecord.modelUrl;
link.download = `tripo-model-${nextRecord.taskId}.${extractModelFormat(nextRecord.modelUrl)}`;
link.click();
setAutoDownloadTaskId(null);
}
}
if (pollAttemptsRef.current >= 360 && !FINAL_TASK_STATUS.has(status)) {
const timeoutRecord: TaskRecord = {
...nextRecord,
status: "failed",
error: "任务轮询超时,请稍后重试",
};
setCurrentTask(timeoutRecord);
upsertTaskHistory(timeoutRecord);
toast.error("任务轮询超时,请稍后重试");
}
};
void runPoll();
const timer = window.setInterval(() => {
void runPoll();
}, 2000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [autoDownloadTaskId, currentTask, upsertTaskHistory]);
// File handling functions
const handleMultiViewFileChange = (index: number, file: File | null) => {
setMultiViewSlots((prev) => {
const next = [...prev];
const target = next[index];
if (!target) return prev;
if (target.previewUrl) URL.revokeObjectURL(target.previewUrl);
next[index] = {
...target,
file,
previewUrl: file ? URL.createObjectURL(file) : null,
};
return next;
});
};
const clearMultiViewSlot = (index: number) => {
handleMultiViewFileChange(index, null);
};
const setBatchFiles = (files: File[]) => {
const nextFiles = files.slice(0, 10);
setBatchImageSlots((prev) => {
for (const slot of prev) {
if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl);
}
return createBatchImageSlots().map((slot, index) => {
const file = nextFiles[index] || null;
return {
...slot,
file,
previewUrl: file ? URL.createObjectURL(file) : null,
};
});
});
};
const handleBatchPickerChange = (files: FileList | null) => {
const nextFiles = files ? Array.from(files) : [];
setBatchFiles(nextFiles);
};
const handleBatchFileChange = (index: number, file: File | null) => {
setBatchImageSlots((prev) => {
const next = [...prev];
const target = next[index];
if (!target) return prev;
if (target.previewUrl) URL.revokeObjectURL(target.previewUrl);
next[index] = {
...target,
file,
previewUrl: file ? URL.createObjectURL(file) : null,
};
return next;
});
};
const clearBatchSlot = (index: number) => {
handleBatchFileChange(index, null);
};
// Task submission functions
const buildGenerationPayload = (
taskType: "text_to_model" | "image_to_model" | "multiview_to_model"
) => {
const payload: Record<string, any> = {
taskType,
prompt: prompt.trim(),
negativePrompt: negativePrompt.trim(),
modelVersion,
faceLimit,
texture,
pbr,
autoSize,
quad,
generateParts,
smartLowPoly,
geometryQuality,
textureQuality,
modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined,
textureSeed: textureSeed.trim() ? Number(textureSeed) : undefined,
};
if (taskType === "image_to_model" || taskType === "multiview_to_model") {
payload.textureAlignment = textureAlignment;
payload.orientation = orientation;
}
return payload;
};
const submitTask = async (params: {
payload: Record<string, any>;
mode: TaskMode;
promptText: string;
sourceTaskId?: string | null;
exportFormat?: string | null;
}) => {
const createResult = await model3DGenerationService.createTask(
params.payload
);
if (!createResult.success || !createResult.data?.taskId) {
throw new Error(createResult.error || "创建任务失败");
}
const newTask: TaskRecord = {
taskId: createResult.data.taskId,
status: normalizeTaskStatus(createResult.data.status),
prompt: params.promptText,
mode: params.mode,
modelVersion,
createdAt: Date.now(),
sourceTaskId: params.sourceTaskId || null,
exportFormat: params.exportFormat || null,
};
setCurrentTask(newTask);
upsertTaskHistory(newTask);
return newTask;
};
const getReadyTask = () => {
if (!currentTask || currentTask.status !== "success") {
toast.error("请先完成一个成功任务");
return null;
}
return currentTask;
};
const submitDerivedTask = async (params: {
payload: Record<string, any>;
mode: TaskMode;
promptText: string;
sourceTaskId: string;
successMessage: string;
}) => {
setIsSubmitting(true);
try {
await submitTask({
payload: params.payload,
mode: params.mode,
promptText: params.promptText,
sourceTaskId: params.sourceTaskId,
});
toast.success(params.successMessage);
} catch (error) {
toast.error(error instanceof Error ? error.message : "提交任务失败");
} finally {
setIsSubmitting(false);
}
};
const handleGenerate = async () => {
const normalizedPrompt = prompt.trim();
if (mode === "text" && !normalizedPrompt) {
toast.error("请输入提示词");
return;
}
if (mode === "image" && !referenceImage) {
toast.error("图生3D请先上传参考图");
return;
}
const selectedMultiFiles = multiViewSlots
.map((slot) => slot.file)
.filter(Boolean) as File[];
const selectedBatchFiles = batchImageSlots
.map((slot) => slot.file)
.filter(Boolean) as File[];
if (mode === "multiview" && selectedMultiFiles.length < 2) {
toast.error("多视图生成至少上传 2 张图片");
return;
}
if (mode === "batch" && selectedBatchFiles.length < 1) {
toast.error("批量图生请至少上传 1 张图片");
return;
}
setIsSubmitting(true);
setUploadingImage(false);
try {
if (mode === "text") {
await submitTask({
payload: buildGenerationPayload("text_to_model"),
mode: "text",
promptText: normalizedPrompt,
});
toast.success("已提交文生3D任务");
return;
}
if (mode === "image") {
setUploadingImage(true);
const uploadResult = await model3DGenerationService.uploadImage(
referenceImage as File
);
if (!uploadResult.success || !uploadResult.data) {
throw new Error(uploadResult.error || "上传参考图失败");
}
const payload = buildGenerationPayload("image_to_model");
if (uploadResult.data.imageToken) {
payload.imageToken = uploadResult.data.imageToken;
} else if (uploadResult.data.file) {
payload.file = uploadResult.data.file;
} else {
throw new Error("参考图上传未返回可用 token");
}
await submitTask({
payload,
mode: "image",
promptText: normalizedPrompt || "图生3D",
});
toast.success("已提交图生3D任务");
return;
}
if (mode === "batch") {
setUploadingImage(true);
let successCount = 0;
for (const file of selectedBatchFiles.slice(0, 10)) {
const uploadResult = await model3DGenerationService.uploadImage(file);
if (!uploadResult.success || !uploadResult.data) {
throw new Error(uploadResult.error || `上传失败: ${file.name}`);
}
const payload = buildGenerationPayload("image_to_model");
if (uploadResult.data.imageToken) {
payload.imageToken = uploadResult.data.imageToken;
} else if (uploadResult.data.file) {
payload.file = uploadResult.data.file;
} else {
throw new Error(`图片未返回可用 token: ${file.name}`);
}
await submitTask({
payload,
mode: "batch",
promptText: `${normalizedPrompt || "批量图生3D"} · ${file.name}`,
});
successCount += 1;
}
toast.success(`已提交 ${successCount} 个批量图生3D任务`);
return;
}
setUploadingImage(true);
const uploadResults = await Promise.all(
selectedMultiFiles.map(async (file) =>
model3DGenerationService.uploadImage(file)
)
);
const filesPayload: Array<Record<string, any>> = [];
for (const result of uploadResults) {
if (!result.success || !result.data) {
throw new Error(result.error || "上传多视图图片失败");
}
if (result.data.imageToken) {
filesPayload.push({
type: "jpg",
file_token: result.data.imageToken,
});
} else if (result.data.file) {
filesPayload.push(result.data.file);
}
}
if (filesPayload.length < 2) {
throw new Error("多视图图片上传失败,请重试");
}
const payload = buildGenerationPayload("multiview_to_model");
payload.files = filesPayload;
await submitTask({
payload,
mode: "multiview",
promptText: normalizedPrompt || "多视图3D",
});
toast.success("已提交多视图3D任务");
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建任务失败");
} finally {
setUploadingImage(false);
setIsSubmitting(false);
}
};
const handleImportToCanvas = async () => {
if (!currentModelUrl || !currentTask || currentTask.status !== "success") {
toast.error("暂无可导入的 3D 模型");
return;
}
const viewportX = viewport.x || 0;
const viewportY = viewport.y || 0;
const viewportZoom = viewport.zoom || 1;
const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
const stageHeight =
typeof window !== "undefined" ? window.innerHeight : 720;
const targetWidth = 380;
const targetHeight = 300;
const x = (stageWidth * 0.48 - viewportX) / viewportZoom - targetWidth / 2;
const y =
(stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2;
addNode({
type: "model3d",
position: { x, y },
size: { width: targetWidth, height: targetHeight },
data: {
src: currentModelUrl,
previewImage: currentTask.previewImageUrl || undefined,
prompt: currentTask.prompt,
modelVersion: currentTask.modelVersion,
format: extractModelFormat(currentModelUrl),
status: "completed",
metadata: {
taskId: currentTask.taskId,
mode: currentTask.mode,
sourceTaskId: currentTask.sourceTaskId,
progress: currentTask.progress,
triangles: viewerStats.triangles,
vertices: viewerStats.vertices,
meshCount: viewerStats.meshCount,
},
},
connections: [],
});
try {
await canvasService.saveCanvasState(id, getCanvasState());
} catch (error) {
console.error("[Model3DPage] 保存画布失败:", error);
}
toast.success("3D 模型已导入画布");
};
const handleDownloadCurrent = () => {
if (!currentModelUrl) {
toast.error("暂无可下载的 3D 模型");
return;
}
const link = document.createElement("a");
link.href = currentModelUrl;
link.download = `tripo-model-${currentTask?.taskId || "result"}.${extractModelFormat(
currentModelUrl
)}`;
link.click();
};
const handleConvertAndDownload = async () => {
const readyTask = getReadyTask();
if (!readyTask) return;
setIsSubmitting(true);
try {
const baseTaskId = readyTask.taskId;
const payload: Record<string, any> = {
taskType: "convert_model",
originalModelTaskId: baseTaskId,
format: exportFormat,
textureSize: 4096,
textureFormat: "JPEG",
};
const task = await submitTask({
payload,
mode: "convert",
promptText: `${modeLabel(readyTask.mode)} -> ${exportFormat}`,
sourceTaskId: baseTaskId,
exportFormat,
});
setAutoDownloadTaskId(task.taskId);
toast.success(`已提交${exportFormat}转换任务,完成后自动下载`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "格式转换失败");
} finally {
setIsSubmitting(false);
}
};
const handleRefineModel = async () => {
const readyTask = getReadyTask();
if (!readyTask) return;
setIsSubmitting(true);
try {
const payload: Record<string, any> = {
taskType: "refine_model",
draftModelTaskId: readyTask.taskId,
};
await submitTask({
payload,
mode: "refine",
promptText: `精修任务(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
});
toast.success("已提交模型精修任务");
} catch (error) {
toast.error(error instanceof Error ? error.message : "提交精修任务失败");
} finally {
setIsSubmitting(false);
}
};
const handleTextureModel = async () => {
const readyTask = getReadyTask();
if (!readyTask) return;
await submitDerivedTask({
payload: {
taskType: "texture_model",
originalModelTaskId: readyTask.taskId,
texture,
pbr,
textureQuality,
textureAlignment,
modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined,
textureSeed: textureSeed.trim() ? Number(textureSeed) : undefined,
texturePromptText: texturePromptText.trim() || undefined,
},
mode: "texture",
promptText: `纹理重绘(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交纹理重绘任务",
});
};
const handleStylizeModel = async () => {
const readyTask = getReadyTask();
if (!readyTask) return;
await submitDerivedTask({
payload: {
taskType: "stylize_model",
originalModelTaskId: readyTask.taskId,
style: stylizeStyle,
blockSize: Number.isFinite(stylizeBlockSize)
? stylizeBlockSize
: undefined,
},
mode: "stylize",
promptText: `风格化 ${stylizeStyle}(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交风格化任务",
});
};
const handleRigCheck = async () => {
const readyTask = getReadyTask();
if (!readyTask) return;
await submitDerivedTask({
payload: {
taskType: "animate_prerigcheck",
originalModelTaskId: readyTask.taskId,
},
mode: "rigcheck",
promptText: `可绑定检测(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交可绑定检测任务",
});
};
const handleRigModel = async () => {
const readyTask = getReadyTask();
if (!readyTask) return;
await submitDerivedTask({
payload: {
taskType: "animate_rig",
originalModelTaskId: readyTask.taskId,
outFormat: rigFormat,
rigType,
spec: rigSpec,
},
mode: "rig",
promptText: `自动绑定 ${rigType}(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交自动绑定任务",
});
};
const handleRetargetAnimation = async () => {
const readyTask = getReadyTask();
if (!readyTask) return;
await submitDerivedTask({
payload: {
taskType: "animate_retarget",
originalModelTaskId: readyTask.taskId,
animation: retargetAnimation,
outFormat: rigFormat,
bakeAnimation: retargetBake,
exportWithGeometry: retargetExportWithGeometry,
animateInPlace: retargetInPlace,
},
mode: "retarget",
promptText: `动作 ${retargetAnimation}(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交动作重定向任务",
});
};
const handleMeshSegmentation = async () => {
const readyTask = getReadyTask();
if (!readyTask) return;
await submitDerivedTask({
payload: {
taskType: "mesh_segmentation",
originalModelTaskId: readyTask.taskId,
},
mode: "segment",
promptText: `网格分割(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交网格分割任务",
});
};
const handleMeshCompletion = async () => {
const readyTask = getReadyTask();
if (!readyTask) return;
const parts = completionPartNames
.split(",")
.map((item) => item.trim())
.filter(Boolean);
await submitDerivedTask({
payload: {
taskType: "mesh_completion",
originalModelTaskId: readyTask.taskId,
partNames: parts.length ? parts : undefined,
},
mode: "complete",
promptText: `网格补全(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交网格补全任务",
});
};
const handleLowpolyOptimize = async () => {
const readyTask = getReadyTask();
if (!readyTask) return;
await submitDerivedTask({
payload: {
taskType: "highpoly_to_lowpoly",
originalModelTaskId: readyTask.taskId,
faceLimit: Number.isFinite(lowpolyFaceLimit)
? lowpolyFaceLimit
: undefined,
quad: lowpolyQuad,
bake: lowpolyBake,
},
mode: "lowpoly",
promptText: `低模优化(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交低模优化任务",
});
};
const handleSelectTask = (task: TaskRecord) => {
setCurrentTask(task);
if (task.status === "success" && task.modelUrl) {
setViewerLayers([]);
setLayerVisibility({});
setIsolatedLayerId(null);
}
};
const handleClearHistory = async () => {
const prevTaskHistory = taskHistory;
const prevCurrentTask = currentTask;
setTaskHistory([]);
setCurrentTask(null);
try {
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
method: "DELETE",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success === false) {
throw new Error(payload?.error || "清空资产库失败");
}
if (typeof window !== "undefined") {
window.localStorage.removeItem(legacyHistoryStorageKey);
}
toast.success("资产库已清空");
} catch (error) {
setTaskHistory(prevTaskHistory);
setCurrentTask(prevCurrentTask);
toast.error(error instanceof Error ? error.message : "清空资产库失败");
}
};
const handleLayersChange = useCallback((layers: Model3DLayer[]) => {
setViewerLayers(layers);
setLayerVisibility((prev) => {
const next: Record<string, boolean> = {};
for (const layer of layers) {
next[layer.id] = prev[layer.id] !== false;
}
return next;
});
setIsolatedLayerId((prev) =>
prev && layers.some((layer) => layer.id === prev) ? prev : null
);
}, []);
const importDisabled =
!currentTask || currentTask.status !== "success" || !currentModelUrl;
const activeStatusLabel = currentTask
? statusLabel(currentTask.status)
: "未开始";
const activePromptSummary = currentTask?.prompt?.trim() || "尚未创建任务";
return (
<div className="flex h-dvh w-full flex-col overflow-hidden bg-zinc-950 text-zinc-100">
{/* Top Navigation Bar */}
<header className="flex h-14 shrink-0 items-center justify-between border-white/10 border-b bg-zinc-950 px-4">
<div className="flex items-center gap-6">
{/* Logo & Brand */}
<div
className="flex cursor-pointer items-center gap-2 transition-opacity hover:opacity-80"
onClick={() => router.push("/")}
>
{displayLogo ? (
<img
alt={siteName}
className="h-6 w-6 object-contain"
src={displayLogo}
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-yellow-500/10">
<Sparkles className="h-4 w-4 text-yellow-500" />
</div>
)}
<span className="font-bold text-lg text-white tracking-tight">
{siteName}
</span>
</div>
{/* Divider */}
<div className="h-4 w-[1px] bg-white/10" />
{/* Nav Links */}
<nav className="flex items-center gap-1">
<Button
className="h-8 gap-1 px-2 text-zinc-400 hover:bg-white/5 hover:text-white"
variant="ghost"
>
3D 工作台
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</nav>
</div>
<div className="flex items-center gap-3">
{/* DCC Bridge (Mock) */}
<Button
className="hidden h-8 gap-2 text-zinc-400 hover:bg-white/5 hover:text-white sm:flex"
variant="ghost"
>
<Download className="h-4 w-4" />
<span className="text-xs">DCC Bridge</span>
</Button>
<div className="flex h-8 items-center rounded-full bg-blue-500/10 px-3 text-blue-400">
<span className="font-medium text-xs">积分</span>
<span className="ml-1 font-semibold text-sm">{pointsLabel}</span>
</div>
{/* User Profile */}
<div className="ml-1 h-8 w-8 overflow-hidden rounded-full border border-white/10 bg-zinc-800">
{user?.image ? (
<img
alt={user.name || "User"}
className="h-full w-full object-cover"
src={user.image}
/>
) : (
<div className="flex h-full w-full items-center justify-center font-semibold text-xs text-zinc-200">
{userAvatarFallback}
</div>
)}
</div>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
{/* 1. Left Nav Sidebar (Icon only) */}
<aside className="z-30 flex w-16 flex-col items-center border-white/10 border-r bg-zinc-950 py-4">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="mb-6 h-10 w-10 rounded-xl text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => router.push(`/canvas/${id}`)}
size="icon"
variant="ghost"
>
<ArrowLeft className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">返回画布</TooltipContent>
</Tooltip>
<div className="flex flex-col gap-4">
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn(
"h-10 w-10 rounded-xl",
activeTab === "create"
? "bg-yellow-500 text-black hover:bg-yellow-400"
: "text-zinc-400 hover:bg-white/10 hover:text-white"
)}
onClick={() => setActiveTab("create")}
size="icon"
variant={activeTab === "create" ? "default" : "ghost"}
>
<Sparkles className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">生成模型</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn(
"h-10 w-10 rounded-xl",
activeTab === "edit"
? "bg-yellow-500 text-black hover:bg-yellow-400"
: "text-zinc-400 hover:bg-white/10 hover:text-white"
)}
onClick={() => setActiveTab("edit")}
size="icon"
variant={activeTab === "edit" ? "default" : "ghost"}
>
<WandSparkles className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">编辑与优化</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn(
"h-10 w-10 rounded-xl",
activeTab === "scene"
? "bg-yellow-500 text-black hover:bg-yellow-400"
: "text-zinc-400 hover:bg-white/10 hover:text-white"
)}
onClick={() => setActiveTab("scene")}
size="icon"
variant={activeTab === "scene" ? "default" : "ghost"}
>
<Layers3 className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">场景层级</TooltipContent>
</Tooltip>
</div>
<div className="mt-auto flex flex-col gap-4">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/5 font-bold text-xs text-yellow-500">
{pointsLabel}
</div>
</TooltipTrigger>
<TooltipContent side="right">可用积分</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</aside>
{/* 2. Left Tool Panel (Expandable Content) */}
<div className="z-20 flex w-80 flex-col border-white/10 border-r bg-zinc-900/50 backdrop-blur-sm">
<div className="flex h-14 items-center border-white/10 border-b px-4">
<h2 className="font-semibold text-sm text-white">
{activeTab === "create" && "生成模型"}
{activeTab === "edit" && "模型编辑"}
{activeTab === "scene" && "场景管理"}
</h2>
</div>
<ScrollArea className="flex-1">
<div className="space-y-6 p-4">
{activeTab === "create" && (
<>
<div className="grid grid-cols-4 gap-1 rounded-lg bg-zinc-950 p-1">
{[
{ id: "text", label: "文生", icon: PenLine },
{ id: "image", label: "图生", icon: ImagePlus },
{ id: "multiview", label: "多视", icon: Box },
{ id: "batch", label: "批量", icon: Images },
].map((m) => (
<button
className={cn(
"flex flex-col items-center justify-center gap-1 rounded-md py-2 text-[10px] transition-all",
mode === m.id
? "bg-zinc-800 text-white shadow-sm"
: "text-zinc-500 hover:text-zinc-300"
)}
key={m.id}
onClick={() => setMode(m.id as any)}
>
<m.icon className="h-4 w-4" />
{m.label}
</button>
))}
</div>
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs text-zinc-400">提示词</Label>
<Textarea
className="min-h-24 resize-none border-white/10 bg-zinc-950 text-sm text-zinc-300 placeholder:text-zinc-700 focus-visible:ring-yellow-500/50"
onChange={(e) => setPrompt(e.target.value)}
placeholder="描述你想生成的3D模型..."
value={prompt}
/>
</div>
{mode === "image" && (
<div className="space-y-2">
<Label className="text-xs text-zinc-400">参考图</Label>
<label className="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-white/10 border-dashed bg-zinc-950 px-3 py-6 text-xs text-zinc-500 transition-colors hover:bg-zinc-900/50">
{referencePreviewUrl ? (
<div className="relative w-full">
<img
alt="ref"
className="h-32 w-full rounded-md object-contain"
src={referencePreviewUrl}
/>
<Button
className="-right-2 -top-2 absolute h-6 w-6 rounded-full"
onClick={(e) => {
e.preventDefault();
setReferenceImage(null);
}}
size="icon"
variant="destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<PlusCircle className="h-6 w-6" />
<span>点击上传图片</span>
</>
)}
<Input
accept="image/*"
className="hidden"
onChange={(e) =>
setReferenceImage(e.target.files?.[0] || null)
}
type="file"
/>
</label>
</div>
)}
{/* Multiview slots simplified for brevity, following same dark theme pattern */}
{mode === "multiview" && (
<div className="grid grid-cols-2 gap-2">
{multiViewSlots.map((slot, i) => (
<label
className="relative flex aspect-square cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-white/10 border-dashed bg-zinc-950 hover:bg-zinc-900"
key={slot.id}
>
{slot.previewUrl ? (
<img
className="h-full w-full object-cover"
src={slot.previewUrl}
/>
) : (
<span className="text-xs text-zinc-600">
视图 {i + 1}
</span>
)}
<Input
accept="image/*"
className="hidden"
onChange={(e) =>
handleMultiViewFileChange(
i,
e.target.files?.[0] || null
)
}
type="file"
/>
</label>
))}
</div>
)}
<Collapsible
onOpenChange={setAdvancedOpen}
open={advancedOpen}
>
<CollapsibleTrigger asChild>
<Button
className="w-full justify-between border-white/10 bg-transparent text-zinc-400 hover:bg-white/5 hover:text-white"
variant="outline"
>
高级参数
{advancedOpen ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="space-y-2">
<Label className="text-xs text-zinc-400">
模型版本
</Label>
<Select
onValueChange={(v) => setModelVersion(v as any)}
value={modelVersion}
>
<SelectTrigger className="border-white/10 bg-zinc-950 text-zinc-300">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
{MODEL_VERSION_OPTIONS.map((v) => (
<SelectItem
className="focus:bg-zinc-800 focus:text-white"
key={v}
value={v}
>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Toggles */}
<div className="space-y-3 rounded-lg border border-white/10 bg-zinc-950 p-3">
<div className="flex items-center justify-between text-xs text-zinc-400">
<span>PBR 材质</span>
<Switch
checked={pbr}
className="data-[state=checked]:bg-yellow-500"
onCheckedChange={setPbr}
/>
</div>
<div className="flex items-center justify-between text-xs text-zinc-400">
<span>四边面优化</span>
<Switch
checked={quad}
className="data-[state=checked]:bg-yellow-500"
onCheckedChange={setQuad}
/>
</div>
<div className="flex items-center justify-between text-xs text-zinc-400">
<span>智能低模</span>
<Switch
checked={smartLowPoly}
className="data-[state=checked]:bg-yellow-500"
onCheckedChange={setSmartLowPoly}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<Button
className="w-full bg-yellow-500 text-black hover:bg-yellow-400"
disabled={isSubmitting}
onClick={handleGenerate}
size="lg"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{isSubmitting ? "生成中..." : "开始生成"}
</Button>
</div>
</>
)}
{activeTab === "edit" && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-2">
<Button
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
disabled={importDisabled}
onClick={handleImportToCanvas}
variant="secondary"
>
<PlusCircle className="h-4 w-4" /> 导入画布
</Button>
<Button
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
disabled={!currentModelUrl}
onClick={handleDownloadCurrent}
variant="secondary"
>
<Download className="h-4 w-4" /> 下载
</Button>
</div>
<Separator className="bg-white/10" />
<div className="space-y-3">
<h4 className="flex items-center gap-2 font-medium text-xs text-zinc-400">
<Package className="h-3.5 w-3.5" /> 格式转换
</h4>
<div className="flex gap-2">
<Select
onValueChange={(v) => setExportFormat(v as any)}
value={exportFormat}
>
<SelectTrigger className="flex-1 border-white/10 bg-zinc-950 text-zinc-300">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
{EXPORT_FORMAT_OPTIONS.map((f) => (
<SelectItem
className="focus:bg-zinc-800 focus:text-white"
key={f}
value={f}
>
{f}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
disabled={
isSubmitting ||
!currentTask ||
currentTask.status !== "success"
}
onClick={handleConvertAndDownload}
variant="outline"
>
转换
</Button>
</div>
</div>
<div className="space-y-2">
<h4 className="mb-2 font-medium text-xs text-zinc-400">
常用操作
</h4>
<Button
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
disabled={
isSubmitting ||
!currentTask ||
currentTask.status !== "success"
}
onClick={handleRefineModel}
variant="outline"
>
<Sparkles className="mr-2 h-4 w-4" /> 模型精修
</Button>
<Button
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
disabled={
isSubmitting ||
!currentTask ||
currentTask.status !== "success"
}
onClick={handleTextureModel}
variant="outline"
>
<Palette className="mr-2 h-4 w-4" /> 纹理重绘
</Button>
<Button
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
disabled={
isSubmitting ||
!currentTask ||
currentTask.status !== "success"
}
onClick={handleRigModel}
variant="outline"
>
<Bone className="mr-2 h-4 w-4" /> 自动绑定
</Button>
</div>
</div>
)}
{activeTab === "scene" && (
<div className="space-y-4">
<div className="rounded-lg border border-white/10 bg-zinc-950 p-4">
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="font-bold text-lg text-white">
{viewerStats.triangles.toLocaleString()}
</div>
<div className="text-xs text-zinc-500">面数</div>
</div>
<div>
<div className="font-bold text-lg text-white">
{viewerStats.vertices.toLocaleString()}
</div>
<div className="text-xs text-zinc-500">顶点</div>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-xs text-zinc-400">
网格列表 ({viewerLayers.length})
</h4>
{viewerLayers.map((layer) => (
<div
className="flex items-center justify-between rounded-md bg-zinc-900 p-2 text-xs text-zinc-300"
key={layer.id}
>
<span className="truncate">{layer.name}</span>
<div className="flex gap-1">
<Button
className="h-6 w-6 hover:text-white"
onClick={() =>
setLayerVisibility((prev) => ({
...prev,
[layer.id]: prev[layer.id] === false,
}))
}
size="icon"
variant="ghost"
>
{layerVisibility[layer.id] === false ? (
<EyeOff className="h-3 w-3" />
) : (
<Eye className="h-3 w-3" />
)}
</Button>
<Button
className="h-6 w-6 hover:text-white"
onClick={() =>
setIsolatedLayerId((prev) =>
prev === layer.id ? null : layer.id
)
}
size="icon"
variant="ghost"
>
<Maximize className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
</div>
{/* 3. Main Viewer Area */}
<div className="relative flex-1 bg-black">
<Model3DViewer
layerVisibility={effectiveLayerVisibility}
modelUrl={currentModelUrl}
onLayersChange={handleLayersChange}
onStatsChange={setViewerStats}
previewImageUrl={currentPreviewImage}
settings={viewerSettings}
/>
{/* Floating Toolbar */}
<div className="-translate-x-1/2 absolute bottom-8 left-1/2 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/80 p-2 shadow-2xl backdrop-blur-md">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
onClick={() =>
setViewerSettings((prev) => ({
...prev,
showGrid: !prev.showGrid,
}))
}
size="icon"
variant={viewerSettings.showGrid ? "secondary" : "ghost"}
>
<Grid3X3 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>网格</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
onClick={() =>
setViewerSettings((prev) => ({
...prev,
autoRotate: !prev.autoRotate,
}))
}
size="icon"
variant={viewerSettings.autoRotate ? "secondary" : "ghost"}
>
<RotateCw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>自动旋转</TooltipContent>
</Tooltip>
<Separator className="h-4 bg-white/20" orientation="vertical" />
<div className="flex items-center gap-2 px-2">
<Sun className="h-3 w-3 text-zinc-400" />
<Slider
className="w-20"
max={2}
min={0.2}
onValueChange={(v) =>
setViewerSettings((prev) => ({
...prev,
lightIntensity: v[0] || 1,
}))
}
step={0.1}
value={[viewerSettings.lightIntensity]}
/>
</div>
</TooltipProvider>
</div>
</div>
{/* 4. Right Assets Panel */}
<div className="z-20 flex w-72 flex-col border-white/10 border-l bg-zinc-900/50 backdrop-blur-sm">
<div className="flex h-14 items-center justify-between border-white/10 border-b px-4">
<h2 className="font-semibold text-sm text-white">资产库</h2>
<Button
className="h-8 w-8 text-zinc-400 hover:text-white"
onClick={handleClearHistory}
size="icon"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="flex-1">
<div className="grid grid-cols-2 gap-2 p-3">
{taskHistory.map((task) => (
<button
className={cn(
"group relative aspect-square overflow-hidden rounded-lg border border-white/5 bg-zinc-950 transition-all hover:border-yellow-500/50",
currentTask?.taskId === task.taskId &&
"border-yellow-500 ring-1 ring-yellow-500/20"
)}
key={task.taskId}
onClick={() => handleSelectTask(task)}
>
{task.previewImageUrl ? (
<img
alt="preview"
className="h-full w-full object-cover transition-transform group-hover:scale-110"
src={task.previewImageUrl}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-700">
<Box className="h-8 w-8" />
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 pt-6">
<div className="flex items-center justify-between">
<Badge
className="h-4 px-1 text-[9px]"
variant={
task.status === "success" ? "default" : "secondary"
}
>
{statusLabel(task.status)}
</Badge>
</div>
</div>
</button>
))}
{taskHistory.length === 0 && (
<div className="col-span-2 py-10 text-center text-xs text-zinc-500">
暂无历史记录
</div>
)}
</div>
</ScrollArea>
</div>
</div>
</div>
);
}