From bfcb777ba4f0ad18d267f41d46cba4dc5178ecc4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 6 Mar 2026 12:16:31 +0800 Subject: [PATCH] feat: persist 3d assets and add configurable 3d points billing --- .../settings/admin-settings-client.tsx | 28 ++ app/api/canvas/3d/tasks/route.ts | 68 +++ app/api/canvas/[id]/model3d-assets/route.ts | 268 +++++++++++ app/api/user/account-center/route.ts | 1 + app/canvas/[id]/model3d/page.tsx | 432 ++++++++++++------ lib/db/migrations/0049_add_model3d_assets.sql | 36 ++ lib/db/migrations/meta/_journal.json | 7 + lib/db/schema.ts | 26 ++ lib/points/config.ts | 7 + 9 files changed, 738 insertions(+), 135 deletions(-) create mode 100644 app/api/canvas/[id]/model3d-assets/route.ts create mode 100644 lib/db/migrations/0049_add_model3d_assets.sql 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() { /> +
+
+ +
+ 作用于 3D 生成功能(文生3D / 图生3D / 多视图3D),每次提交任务扣除一次。 +
+
+
+ + setSiteSettings({ + ...siteSettings, + pointsSystemConfig: { + ...siteSettings.pointsSystemConfig, + creditCostPer3D: Math.max( + 0, + Number.parseInt(e.target.value || "0", 10) || 0 + ), + }, + }) + } + /> +
+
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, }; }