feat: persist 3d assets and add configurable 3d points billing
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-06 12:16:31 +08:00
parent 31fba91269
commit bfcb777ba4
9 changed files with 738 additions and 135 deletions

View File

@ -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>

View File

@ -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: {

View 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 });
}
}

View File

@ -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 "管理员赠送";

View File

@ -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>

View 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");

View File

@ -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
}
]
}

View File

@ -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(),

View File

@ -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,
};
}