diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index f0e085a82..dde871a47 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -4601,6 +4601,34 @@ export function AdminSettingsClient() {
/>
+
diff --git a/app/api/canvas/3d/tasks/route.ts b/app/api/canvas/3d/tasks/route.ts
index f382fdda6..fc04faac3 100644
--- a/app/api/canvas/3d/tasks/route.ts
+++ b/app/api/canvas/3d/tasks/route.ts
@@ -1,5 +1,10 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
+import {
+ consumeUserPoints,
+ getPointsSystemConfig,
+ getUserById,
+} from "@/lib/db/queries";
import { getTripoProviderConfig } from "@/lib/tripo/server";
import { TripoApiError, tripoRequest } from "@/lib/tripo/client";
@@ -96,6 +101,14 @@ function assignMapped(
}
}
+function shouldCharge3DPoints(taskType: CreateTaskRequest["taskType"]) {
+ return (
+ taskType === "text_to_model" ||
+ taskType === "image_to_model" ||
+ taskType === "multiview_to_model"
+ );
+}
+
export async function POST(request: Request) {
try {
const session = await auth();
@@ -326,6 +339,37 @@ export async function POST(request: Request) {
payload.compress = "geometry";
}
+ const pointsConfig = await getPointsSystemConfig();
+ const shouldChargePoints = shouldCharge3DPoints(taskType);
+ const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3D))
+ ? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3D)))
+ : 0;
+
+ if (
+ shouldChargePoints &&
+ pointsConfig.enabled &&
+ unitCost > 0 &&
+ pointsConfig.blockOnInsufficient !== false
+ ) {
+ const userData = await getUserById({ id: session.user.id });
+ const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0));
+
+ if (currentBalance < unitCost) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: "积分不足",
+ code: "insufficient_points",
+ message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
+ balance: currentBalance,
+ expectedCost: unitCost,
+ unitCost,
+ },
+ { status: 402 }
+ );
+ }
+ }
+
const config = await getTripoProviderConfig();
const result = await tripoRequest>(config, {
method: "POST",
@@ -346,6 +390,30 @@ export async function POST(request: Request) {
);
}
+ if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
+ try {
+ const consumeResult = await consumeUserPoints({
+ userId: session.user.id,
+ amount: unitCost,
+ reason: "model3d_generation",
+ modelId: "tripo-3d",
+ note: "3D生成消耗积分",
+ });
+
+ if (!consumeResult.ok) {
+ console.warn(
+ `[Points] Failed to consume 3D points. user=${session.user.id} task=${taskId} cost=${unitCost}`
+ );
+ } else {
+ console.log(
+ `[Points] Consumed ${unitCost} points for 3D generation. user=${session.user.id} task=${taskId} balance=${consumeResult.balance}`
+ );
+ }
+ } catch (error) {
+ console.error("[Points] Failed to consume 3D points:", error);
+ }
+ }
+
return NextResponse.json({
success: true,
data: {
diff --git a/app/api/canvas/[id]/model3d-assets/route.ts b/app/api/canvas/[id]/model3d-assets/route.ts
new file mode 100644
index 000000000..14ffa467d
--- /dev/null
+++ b/app/api/canvas/[id]/model3d-assets/route.ts
@@ -0,0 +1,268 @@
+import { and, desc, eq } from "drizzle-orm";
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/app/(auth)/auth";
+import { db } from "@/lib/db";
+import { canvasProject, model3dAsset } from "@/lib/db/schema";
+
+type Model3DTaskStatus =
+ | "queued"
+ | "running"
+ | "success"
+ | "failed"
+ | "banned"
+ | "expired"
+ | "cancelled"
+ | "unknown";
+
+type Model3DTaskMode =
+ | "text"
+ | "image"
+ | "multiview"
+ | "batch"
+ | "convert"
+ | "refine"
+ | "texture"
+ | "stylize"
+ | "rigcheck"
+ | "rig"
+ | "retarget"
+ | "segment"
+ | "complete"
+ | "lowpoly";
+
+const FINAL_TASK_STATUS = new Set([
+ "queued",
+ "running",
+ "success",
+ "failed",
+ "banned",
+ "expired",
+ "cancelled",
+ "unknown",
+]);
+
+const TASK_MODE_SET = new Set([
+ "text",
+ "image",
+ "multiview",
+ "batch",
+ "convert",
+ "refine",
+ "texture",
+ "stylize",
+ "rigcheck",
+ "rig",
+ "retarget",
+ "segment",
+ "complete",
+ "lowpoly",
+]);
+
+const TASK_LIST_LIMIT = 120;
+
+function normalizeTaskStatus(input: unknown): Model3DTaskStatus {
+ const value = String(input || "").trim() as Model3DTaskStatus;
+ return FINAL_TASK_STATUS.has(value) ? value : "unknown";
+}
+
+function normalizeTaskMode(input: unknown): Model3DTaskMode {
+ const value = String(input || "").trim() as Model3DTaskMode;
+ return TASK_MODE_SET.has(value) ? value : "text";
+}
+
+function readOptionalText(input: unknown): string | null {
+ const normalized = String(input || "").trim();
+ return normalized ? normalized : null;
+}
+
+function readProgress(input: unknown): number | null {
+ if (input === undefined || input === null || input === "") return null;
+ const value = Number(input);
+ if (!Number.isFinite(value)) return null;
+ return Math.max(0, Math.min(100, Math.round(value)));
+}
+
+function readDate(input: unknown): Date | null {
+ if (!input) return null;
+ if (input instanceof Date && !Number.isNaN(input.getTime())) return input;
+ if (typeof input === "number" && Number.isFinite(input)) {
+ const fromNumber = new Date(input);
+ if (!Number.isNaN(fromNumber.getTime())) return fromNumber;
+ }
+ if (typeof input === "string" && input.trim()) {
+ const fromText = new Date(input);
+ if (!Number.isNaN(fromText.getTime())) return fromText;
+ }
+ return null;
+}
+
+async function ensureProjectOwnership(projectId: string, userId: string) {
+ const [project] = await db
+ .select({ id: canvasProject.id })
+ .from(canvasProject)
+ .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId)))
+ .limit(1);
+ return Boolean(project);
+}
+
+export async function GET(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
+ }
+
+ const { id: projectId } = await params;
+ const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
+ if (!ownsProject) {
+ return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
+ }
+
+ const assets = await db
+ .select()
+ .from(model3dAsset)
+ .where(
+ and(
+ eq(model3dAsset.projectId, projectId),
+ eq(model3dAsset.userId, session.user.id)
+ )
+ )
+ .orderBy(desc(model3dAsset.updatedAt))
+ .limit(TASK_LIST_LIMIT);
+
+ return NextResponse.json({ success: true, data: assets });
+ } catch (error) {
+ console.error("[Model3DAssets] GET failed:", error);
+ return NextResponse.json({ success: false, error: "获取 3D 资产失败" }, { status: 500 });
+ }
+}
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
+ }
+
+ const { id: projectId } = await params;
+ const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
+ if (!ownsProject) {
+ return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
+ }
+
+ const body = await request.json().catch(() => ({}));
+ const rawAsset = ((body as any)?.asset || body || {}) as Record;
+ const taskId = String(rawAsset.taskId || "").trim();
+ if (!taskId) {
+ return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
+ }
+
+ const status = normalizeTaskStatus(rawAsset.status);
+ const mode = normalizeTaskMode(rawAsset.mode);
+ const prompt = String(rawAsset.prompt || "").trim();
+ const modelVersion = String(rawAsset.modelVersion || "").trim();
+ const progress = readProgress(rawAsset.progress);
+ const modelUrl = readOptionalText(rawAsset.modelUrl);
+ const previewImageUrl = readOptionalText(rawAsset.previewImageUrl);
+ const error = readOptionalText(rawAsset.error);
+ const sourceTaskId = readOptionalText(rawAsset.sourceTaskId);
+ const exportFormat = readOptionalText(rawAsset.exportFormat);
+ const createdAt = readDate(rawAsset.createdAt);
+
+ const now = new Date();
+ const [saved] = await db
+ .insert(model3dAsset)
+ .values({
+ userId: session.user.id,
+ projectId,
+ taskId,
+ status,
+ mode,
+ prompt,
+ modelVersion,
+ progress,
+ modelUrl,
+ previewImageUrl,
+ error,
+ sourceTaskId,
+ exportFormat,
+ createdAt: createdAt || now,
+ })
+ .onConflictDoUpdate({
+ target: [model3dAsset.projectId, model3dAsset.taskId],
+ set: {
+ userId: session.user.id,
+ status,
+ mode,
+ prompt,
+ modelVersion,
+ progress,
+ modelUrl,
+ previewImageUrl,
+ error,
+ sourceTaskId,
+ exportFormat,
+ updatedAt: now,
+ },
+ })
+ .returning();
+
+ return NextResponse.json({ success: true, data: saved });
+ } catch (error) {
+ console.error("[Model3DAssets] POST failed:", error);
+ return NextResponse.json({ success: false, error: "保存 3D 资产失败" }, { status: 500 });
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
+ }
+
+ const { id: projectId } = await params;
+ const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
+ if (!ownsProject) {
+ return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
+ }
+
+ const searchTaskId = request.nextUrl.searchParams.get("taskId");
+ let bodyTaskId = "";
+ if (!searchTaskId) {
+ const body = await request.json().catch(() => ({}));
+ bodyTaskId = String((body as any)?.taskId || "").trim();
+ }
+ const taskId = String(searchTaskId || bodyTaskId || "").trim();
+
+ const conditions = [
+ eq(model3dAsset.projectId, projectId),
+ eq(model3dAsset.userId, session.user.id),
+ ];
+ if (taskId) {
+ conditions.push(eq(model3dAsset.taskId, taskId));
+ }
+
+ const deleted = await db
+ .delete(model3dAsset)
+ .where(and(...conditions))
+ .returning({ id: model3dAsset.id });
+
+ return NextResponse.json({
+ success: true,
+ data: { deletedCount: deleted.length },
+ });
+ } catch (error) {
+ console.error("[Model3DAssets] DELETE failed:", error);
+ return NextResponse.json({ success: false, error: "清理 3D 资产失败" }, { status: 500 });
+ }
+}
diff --git a/app/api/user/account-center/route.ts b/app/api/user/account-center/route.ts
index 72140bdbb..8b2f30582 100644
--- a/app/api/user/account-center/route.ts
+++ b/app/api/user/account-center/route.ts
@@ -22,6 +22,7 @@ const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
function mapLedgerReasonLabel(reason: string) {
if (reason === "image_generation") return "图片生成";
+ if (reason === "model3d_generation") return "3D生成";
if (reason === "video_generation") return "视频生成";
if (reason === "deduct") return "管理员扣减";
if (reason === "gift") return "管理员赠送";
diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx
index 6d84a4c2e..b2e3ee2cb 100644
--- a/app/canvas/[id]/model3d/page.tsx
+++ b/app/canvas/[id]/model3d/page.tsx
@@ -4,18 +4,14 @@ import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import {
ArrowLeft,
- Bell,
Bone,
Box,
Boxes,
ChevronDown,
ChevronUp,
- CircleDollarSign,
- Crown,
Download,
Eye,
EyeOff,
- Globe,
Grid3X3,
History,
Home,
@@ -36,10 +32,8 @@ import {
Settings2,
Sparkles,
Sun,
- User,
WandSparkles,
X,
- Zap,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
@@ -124,6 +118,21 @@ type TaskRecord = {
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;
@@ -192,6 +201,22 @@ const FINAL_TASK_STATUS = new Set([
"cancelled",
"unknown",
]);
+const TASK_MODE_SET = new Set([
+ "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";
@@ -211,25 +236,6 @@ function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus {
}
}
-function formatBalance(data: Record | undefined): string | null {
- if (!data || typeof data !== "object") return null;
- const candidates = [
- data.balance,
- data.available_balance,
- data.credits,
- data.amount,
- ];
- for (const value of candidates) {
- if (typeof value === "number" && Number.isFinite(value)) {
- return String(value);
- }
- if (typeof value === "string" && value.trim()) {
- return value.trim();
- }
- }
- return null;
-}
-
function extractModelFormat(modelUrl?: string | null): string {
const value = String(modelUrl || "").toLowerCase();
if (!value) return "3d";
@@ -319,11 +325,90 @@ 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 historyStorageKey = useMemo(() => makeStorageKey(id), [id]);
+ const legacyHistoryStorageKey = useMemo(() => makeStorageKey(id), [id]);
const { data: session } = useSession();
const user = session?.user;
@@ -374,7 +459,6 @@ export default function CanvasModel3DPage() {
const [taskHistory, setTaskHistory] = useState([]);
const [currentTask, setCurrentTask] = useState(null);
- const [balanceLabel, setBalanceLabel] = useState(null);
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState(
null
);
@@ -435,10 +519,31 @@ export default function CanvasModel3DPage() {
// 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 =
@@ -447,13 +552,6 @@ export default function CanvasModel3DPage() {
// Effect hooks for data loading and cleanup
useEffect(() => {
console.log("CanvasModel3DPage mounted - v3.0 Dark Mode");
- const loadBalance = async () => {
- const result = await model3DGenerationService.getBalance();
- if (!result.success) return;
- const label = formatBalance(result.data);
- if (label) setBalanceLabel(label);
- };
- void loadBalance();
}, []);
useEffect(() => {
@@ -481,33 +579,124 @@ export default function CanvasModel3DPage() {
};
}, []);
- useEffect(() => {
- if (typeof window === "undefined") return;
- const raw = window.localStorage.getItem(historyStorageKey);
- if (!raw) return;
- try {
- const parsed = JSON.parse(raw) as TaskRecord[];
- if (!Array.isArray(parsed)) return;
- const normalized = parsed
- .filter((item) => item && typeof item.taskId === "string")
- .slice(0, TASK_HISTORY_LIMIT);
- setTaskHistory(normalized);
- const pending = normalized.find(
- (item) => !FINAL_TASK_STATUS.has(item.status)
- );
- setCurrentTask(pending || normalized[0] || null);
- } catch {
- window.localStorage.removeItem(historyStorageKey);
- }
- }, [historyStorageKey]);
+ 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 (typeof window === "undefined") return;
- window.localStorage.setItem(
- historyStorageKey,
- JSON.stringify(taskHistory.slice(0, TASK_HISTORY_LIMIT))
- );
- }, [historyStorageKey, taskHistory]);
+ 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) {
@@ -546,17 +735,25 @@ export default function CanvasModel3DPage() {
};
}, []);
- const upsertTaskHistory = useCallback((next: TaskRecord) => {
- setTaskHistory((prev) => {
- const index = prev.findIndex((item) => item.taskId === next.taskId);
- if (index >= 0) {
- const updated = [...prev];
- updated[index] = { ...updated[index], ...next };
- return updated.slice(0, TASK_HISTORY_LIMIT);
+ 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);
}
- return [next, ...prev].slice(0, TASK_HISTORY_LIMIT);
- });
- }, []);
+ },
+ [persistTaskRecord]
+ );
const effectiveLayerVisibility = useMemo(() => {
if (!isolatedLayerId) return layerVisibility;
@@ -618,8 +815,10 @@ export default function CanvasModel3DPage() {
error:
status === "failed" ? result.data.errorMessage || "任务失败" : null,
};
- setCurrentTask(nextRecord);
- upsertTaskHistory(nextRecord);
+ if (taskRecordChanged(currentTask, nextRecord)) {
+ setCurrentTask(nextRecord);
+ upsertTaskHistory(nextRecord);
+ }
if (status === "success") {
toast.success(`${modeLabel(nextRecord.mode)}任务已完成`);
@@ -1222,11 +1421,28 @@ export default function CanvasModel3DPage() {
}
};
- const handleClearHistory = () => {
+ const handleClearHistory = async () => {
+ const prevTaskHistory = taskHistory;
+ const prevCurrentTask = currentTask;
setTaskHistory([]);
setCurrentTask(null);
- if (typeof window !== "undefined") {
- window.localStorage.removeItem(historyStorageKey);
+
+ 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 : "清空资产库失败");
}
};
@@ -1289,30 +1505,6 @@ export default function CanvasModel3DPage() {
3D 工作台
-
-
-
-
-
-
@@ -1326,41 +1518,11 @@ export default function CanvasModel3DPage() {
DCC Bridge
- {/* Credits */}
-
-
-
{balanceLabel || "0"}
+
+ 积分
+ {pointsLabel}
- {/* Upgrade Button */}
-
-
- {/* Divider */}
-
-
- {/* Icons */}
-
-
-
-
{/* User Profile */}
{user?.image ? (
@@ -1370,8 +1532,8 @@ export default function CanvasModel3DPage() {
src={user.image}
/>
) : (
-
-
+
+ {userAvatarFallback}
)}
@@ -1459,10 +1621,10 @@ export default function CanvasModel3DPage() {
- {balanceLabel || "-"}
+ {pointsLabel}
- 剩余额度
+ 可用积分
diff --git a/lib/db/migrations/0049_add_model3d_assets.sql b/lib/db/migrations/0049_add_model3d_assets.sql
new file mode 100644
index 000000000..102fbfcc4
--- /dev/null
+++ b/lib/db/migrations/0049_add_model3d_assets.sql
@@ -0,0 +1,36 @@
+CREATE TABLE IF NOT EXISTS "Model3DAsset" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "userId" uuid NOT NULL,
+ "projectId" uuid NOT NULL,
+ "taskId" varchar(128) NOT NULL,
+ "status" varchar(32) DEFAULT 'queued' NOT NULL,
+ "mode" varchar(32) DEFAULT 'text' NOT NULL,
+ "prompt" text DEFAULT '' NOT NULL,
+ "modelVersion" varchar(64) DEFAULT '' NOT NULL,
+ "progress" integer,
+ "modelUrl" text,
+ "previewImageUrl" text,
+ "error" text,
+ "sourceTaskId" varchar(128),
+ "exportFormat" varchar(32),
+ "createdAt" timestamp DEFAULT now() NOT NULL,
+ "updatedAt" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+DO $$ BEGIN
+ ALTER TABLE "Model3DAsset" ADD CONSTRAINT "Model3DAsset_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+--> statement-breakpoint
+DO $$ BEGIN
+ ALTER TABLE "Model3DAsset" ADD CONSTRAINT "Model3DAsset_projectId_CanvasProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."CanvasProject"("id") ON DELETE cascade ON UPDATE no action;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS "Model3DAsset_project_task_uidx"
+ ON "Model3DAsset"("projectId", "taskId");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "Model3DAsset_project_updated_idx"
+ ON "Model3DAsset"("projectId", "updatedAt");
diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json
index 36a13cec0..cd7ece9c6 100644
--- a/lib/db/migrations/meta/_journal.json
+++ b/lib/db/migrations/meta/_journal.json
@@ -246,6 +246,13 @@
"when": 1772688000000,
"tag": "0048_add_video_storyboard_tasks",
"breakpoints": true
+ },
+ {
+ "idx": 35,
+ "version": "7",
+ "when": 1772766180000,
+ "tag": "0049_add_model3d_assets",
+ "breakpoints": true
}
]
}
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 4a2e5282a..fd7d921e7 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -584,6 +584,32 @@ export const canvasNode = pgTable("CanvasNode", {
export type CanvasNode = InferSelectModel
;
+// 3D 资产库记录表(用于模型页资产库持久化)
+export const model3dAsset = pgTable("Model3DAsset", {
+ id: uuid("id").primaryKey().notNull().defaultRandom(),
+ userId: uuid("userId")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ projectId: uuid("projectId")
+ .notNull()
+ .references(() => canvasProject.id, { onDelete: "cascade" }),
+ taskId: varchar("taskId", { length: 128 }).notNull(),
+ status: varchar("status", { length: 32 }).notNull().default("queued"),
+ mode: varchar("mode", { length: 32 }).notNull().default("text"),
+ prompt: text("prompt").notNull().default(""),
+ modelVersion: varchar("modelVersion", { length: 64 }).notNull().default(""),
+ progress: integer("progress"),
+ modelUrl: text("modelUrl"),
+ previewImageUrl: text("previewImageUrl"),
+ error: text("error"),
+ sourceTaskId: varchar("sourceTaskId", { length: 128 }),
+ exportFormat: varchar("exportFormat", { length: 32 }),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+});
+
+export type Model3DAsset = InferSelectModel;
+
// 九宫格视频分镜任务主表
export const videoStoryboardTask = pgTable("VideoStoryboardTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
diff --git a/lib/points/config.ts b/lib/points/config.ts
index e1db9fd3f..0302315c7 100644
--- a/lib/points/config.ts
+++ b/lib/points/config.ts
@@ -4,6 +4,7 @@ export const POINTS_PACKAGES_KEY = "points_packages";
export type PointsSystemConfig = {
enabled: boolean;
blockOnInsufficient: boolean;
+ creditCostPer3D: number;
};
export type PointsPackage = {
@@ -18,6 +19,7 @@ export type PointsPackage = {
export const defaultPointsSystemConfig: PointsSystemConfig = {
enabled: false,
blockOnInsufficient: true,
+ creditCostPer3D: 0,
};
export const defaultPointsPackages: PointsPackage[] = [
@@ -45,9 +47,14 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
}
const raw = value as Partial;
+ const parsed3DCost = Number(raw.creditCostPer3D);
return {
enabled: raw.enabled === true,
blockOnInsufficient: raw.blockOnInsufficient !== false,
+ creditCostPer3D:
+ Number.isFinite(parsed3DCost) && parsed3DCost > 0
+ ? Math.min(100000, Math.round(parsed3DCost))
+ : 0,
};
}