feat: persist 3d assets and add configurable 3d points billing
This commit is contained in:
parent
31fba91269
commit
bfcb777ba4
@ -4601,6 +4601,34 @@ export function AdminSettingsClient() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="points3dCost">3D生成单次积分消耗</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
作用于 3D 生成功能(文生3D / 图生3D / 多视图3D),每次提交任务扣除一次。
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-full max-w-xs">
|
||||
<Input
|
||||
id="points3dCost"
|
||||
min={0}
|
||||
type="number"
|
||||
value={siteSettings.pointsSystemConfig?.creditCostPer3D ?? 0}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
pointsSystemConfig: {
|
||||
...siteSettings.pointsSystemConfig,
|
||||
creditCostPer3D: Math.max(
|
||||
0,
|
||||
Number.parseInt(e.target.value || "0", 10) || 0
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>积分套餐</Label>
|
||||
|
||||
@ -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<Record<string, unknown>>(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: {
|
||||
|
||||
268
app/api/canvas/[id]/model3d-assets/route.ts
Normal file
268
app/api/canvas/[id]/model3d-assets/route.ts
Normal file
@ -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<Model3DTaskStatus>([
|
||||
"queued",
|
||||
"running",
|
||||
"success",
|
||||
"failed",
|
||||
"banned",
|
||||
"expired",
|
||||
"cancelled",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
const TASK_MODE_SET = new Set<Model3DTaskMode>([
|
||||
"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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@ -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 "管理员赠送";
|
||||
|
||||
@ -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<Model3DTaskStatus>([
|
||||
"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";
|
||||
|
||||
@ -211,25 +236,6 @@ function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus {
|
||||
}
|
||||
}
|
||||
|
||||
function formatBalance(data: Record<string, any> | 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<TaskRecord[]>([]);
|
||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||
const [balanceLabel, setBalanceLabel] = useState<string | null>(null);
|
||||
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
|
||||
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 工作台
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-8 px-3 text-zinc-400 hover:bg-white/5 hover:text-white"
|
||||
onClick={() => router.push("/")}
|
||||
variant="ghost"
|
||||
>
|
||||
首页
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-8 px-3 text-zinc-400 hover:bg-white/5 hover:text-white"
|
||||
onClick={() => router.push("/gallery")}
|
||||
variant="ghost"
|
||||
>
|
||||
资产
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-8 px-3 text-zinc-400 hover:bg-white/5 hover:text-white"
|
||||
onClick={() => router.push("/featured")}
|
||||
variant="ghost"
|
||||
>
|
||||
广场
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@ -1326,41 +1518,11 @@ export default function CanvasModel3DPage() {
|
||||
<span className="text-xs">DCC Bridge</span>
|
||||
</Button>
|
||||
|
||||
{/* Credits */}
|
||||
<div className="flex h-8 items-center gap-1.5 rounded-full bg-yellow-500/10 px-3 text-yellow-500">
|
||||
<CircleDollarSign className="h-4 w-4" />
|
||||
<span className="font-bold text-sm">{balanceLabel || "0"}</span>
|
||||
<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>
|
||||
|
||||
{/* Upgrade Button */}
|
||||
<Button
|
||||
className="h-8 gap-1 border-0 bg-gradient-to-r from-violet-600 to-indigo-600 px-4 text-white hover:from-violet-500 hover:to-indigo-500"
|
||||
size="sm"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5 fill-current" />
|
||||
<span className="font-medium">升级</span>
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-1 h-4 w-[1px] bg-white/10" />
|
||||
|
||||
{/* Icons */}
|
||||
<Button
|
||||
className="h-8 w-8 text-zinc-400 hover:bg-white/5 hover:text-white"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-8 w-8 text-zinc-400 hover:bg-white/5 hover:text-white"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* User Profile */}
|
||||
<div className="ml-1 h-8 w-8 overflow-hidden rounded-full border border-white/10 bg-zinc-800">
|
||||
{user?.image ? (
|
||||
@ -1370,8 +1532,8 @@ export default function CanvasModel3DPage() {
|
||||
src={user.image}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-zinc-500">
|
||||
<User className="h-4 w-4" />
|
||||
<div className="flex h-full w-full items-center justify-center font-semibold text-xs text-zinc-200">
|
||||
{userAvatarFallback}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -1459,10 +1621,10 @@ export default function CanvasModel3DPage() {
|
||||
<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">
|
||||
{balanceLabel || "-"}
|
||||
{pointsLabel}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">剩余额度</TooltipContent>
|
||||
<TooltipContent side="right">可用积分</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
36
lib/db/migrations/0049_add_model3d_assets.sql
Normal file
36
lib/db/migrations/0049_add_model3d_assets.sql
Normal file
@ -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");
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -584,6 +584,32 @@ export const canvasNode = pgTable("CanvasNode", {
|
||||
|
||||
export type CanvasNode = InferSelectModel<typeof canvasNode>;
|
||||
|
||||
// 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<typeof model3dAsset>;
|
||||
|
||||
// 九宫格视频分镜任务主表
|
||||
export const videoStoryboardTask = pgTable("VideoStoryboardTask", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
|
||||
@ -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<PointsSystemConfig>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user