fix: add 3d model feature visibility and refunds
This commit is contained in:
parent
b64a512dfb
commit
434d4d0d8d
@ -17386,7 +17386,8 @@ export function AdminSettingsClient() {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<CardTitle className="text-xl">3D / 世界模型显示名映射</CardTitle>
|
<CardTitle className="text-xl">3D / 世界模型显示名映射</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
配置 Tripo3D 与 World Labs 的前台展示名称。用户侧只会看到这里配置的模型名,服务端会自动映射到真实模型 ID。
|
配置 Tripo3D 与 World Labs 的前台展示名称,并控制 3D
|
||||||
|
生成附加选项是否显示。用户侧只会看到这里开放的模型和能力。
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<LocalAiSectionToggleButton
|
<LocalAiSectionToggleButton
|
||||||
@ -17404,6 +17405,66 @@ export function AdminSettingsClient() {
|
|||||||
前端提交任务时只会发送系统内部别名,真实模型 ID 只在服务端使用。你可以在这里自由改“展示名”和“真实模型 ID”的对应关系,避免用户看到上游模型名。
|
前端提交任务时只会发送系统内部别名,真实模型 ID 只在服务端使用。你可以在这里自由改“展示名”和“真实模型 ID”的对应关系,避免用户看到上游模型名。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-xl border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-base font-medium">
|
||||||
|
3D 生成附加选项显隐
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
关闭后,用户侧 3D 生成面板会隐藏对应开关;服务端也会忽略该附加选项,避免隐藏功能继续产生额外扣分。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: "pbr",
|
||||||
|
label: "PBR 材质",
|
||||||
|
description: "控制 3D 模型生成里的 PBR 材质开关。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "quad",
|
||||||
|
label: "四边面优化",
|
||||||
|
description: "控制 3D 模型生成里的四边面优化开关。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "smartLowPoly",
|
||||||
|
label: "智能低模",
|
||||||
|
description: "控制 3D 模型生成里的智能低模开关。",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
).map((item) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between gap-3 rounded-xl border bg-muted/10 px-3 py-3"
|
||||||
|
key={item.key}
|
||||||
|
>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-sm font-medium">{item.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={
|
||||||
|
threeDModelAliasConfig.featureVisibility?.[
|
||||||
|
item.key
|
||||||
|
] !== false
|
||||||
|
}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setThreeDModelAliasConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
featureVisibility: {
|
||||||
|
...prev.featureVisibility,
|
||||||
|
[item.key]: checked,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
<div className="space-y-4 rounded-xl border p-4">
|
<div className="space-y-4 rounded-xl border p-4">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { refundUserPoints } from "@/lib/db/queries";
|
||||||
|
import { pointsLedger } from "@/lib/db/schema";
|
||||||
import {
|
import {
|
||||||
TripoApiError,
|
TripoApiError,
|
||||||
pickTaskModelUrl,
|
pickTaskModelUrl,
|
||||||
@ -216,7 +220,87 @@ interface RouteParams {
|
|||||||
params: Promise<{ taskId: string }>;
|
params: Promise<{ taskId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFailedTripoStatus(status?: string | null) {
|
||||||
|
const normalized = String(status || "").trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === "failed" ||
|
||||||
|
normalized === "error" ||
|
||||||
|
normalized === "cancelled" ||
|
||||||
|
normalized === "canceled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModel3DTaskRefundNote(taskId: string) {
|
||||||
|
return `3D模型任务失败退回积分(${taskId})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refundModel3DPointsForFailedTask(params: {
|
||||||
|
userId: string;
|
||||||
|
taskId: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
}) {
|
||||||
|
const chargeLedgers = await db
|
||||||
|
.select({
|
||||||
|
id: pointsLedger.id,
|
||||||
|
amount: pointsLedger.amount,
|
||||||
|
modelId: pointsLedger.modelId,
|
||||||
|
})
|
||||||
|
.from(pointsLedger)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pointsLedger.userId, params.userId),
|
||||||
|
eq(pointsLedger.reason, "model3d_task"),
|
||||||
|
sql`${pointsLedger.note} LIKE ${`3D模型任务消耗积分(% / ${params.taskId})`}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
const chargeLedger = chargeLedgers[0];
|
||||||
|
const refundAmount = Math.abs(Number(chargeLedger?.amount || 0));
|
||||||
|
|
||||||
|
if (!chargeLedger || refundAmount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refundNote = getModel3DTaskRefundNote(params.taskId);
|
||||||
|
const existingRefund = await db
|
||||||
|
.select({ id: pointsLedger.id })
|
||||||
|
.from(pointsLedger)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pointsLedger.userId, params.userId),
|
||||||
|
eq(pointsLedger.reason, "model3d_task_refund"),
|
||||||
|
eq(pointsLedger.note, refundNote),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingRefund[0]) {
|
||||||
|
return refundAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refundResult = await refundUserPoints({
|
||||||
|
userId: params.userId,
|
||||||
|
amount: refundAmount,
|
||||||
|
reason: "model3d_task_refund",
|
||||||
|
modelId: chargeLedger.modelId || "tripo-3d",
|
||||||
|
note: refundNote,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!refundResult.ok) {
|
||||||
|
console.error("[Points] Failed to refund failed 3D model task:", {
|
||||||
|
taskId: params.taskId,
|
||||||
|
userId: params.userId,
|
||||||
|
errorMessage: params.errorMessage,
|
||||||
|
});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return refundAmount;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request, { params }: RouteParams) {
|
export async function GET(request: Request, { params }: RouteParams) {
|
||||||
|
let refundContext: { userId: string; taskId: string } | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@ -228,6 +312,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
if (!normalizedTaskId) {
|
if (!normalizedTaskId) {
|
||||||
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
refundContext = { userId: session.user.id, taskId: normalizedTaskId };
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const preferredProviderId = searchParams.get("providerId");
|
const preferredProviderId = searchParams.get("providerId");
|
||||||
@ -241,6 +326,18 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
|
|
||||||
const output =
|
const output =
|
||||||
result.data?.output && typeof result.data.output === "object" ? result.data.output : {};
|
result.data?.output && typeof result.data.output === "object" ? result.data.output : {};
|
||||||
|
const taskStatus = String(result.data.status || "");
|
||||||
|
const errorMessage = String(result.data.error_msg || "").trim();
|
||||||
|
const refundedPoints = isFailedTripoStatus(taskStatus)
|
||||||
|
? await refundModel3DPointsForFailedTask({
|
||||||
|
userId: session.user.id,
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
errorMessage,
|
||||||
|
}).catch((refundError) => {
|
||||||
|
console.error("[Points] Failed to handle 3D task refund:", refundError);
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
const modelUrl = pickTaskModelUrl(output as Record<string, unknown>);
|
const modelUrl = pickTaskModelUrl(output as Record<string, unknown>);
|
||||||
const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>);
|
const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>);
|
||||||
const stableModelUrl = await archiveTripoModelIfNeeded({
|
const stableModelUrl = await archiveTripoModelIfNeeded({
|
||||||
@ -302,11 +399,28 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
},
|
},
|
||||||
errorCode: result.data.error_code ?? null,
|
errorCode: result.data.error_code ?? null,
|
||||||
errorMessage: result.data.error_msg ?? null,
|
errorMessage: result.data.error_msg ?? null,
|
||||||
|
refundedPoints,
|
||||||
},
|
},
|
||||||
traceId: result.traceId,
|
traceId: result.traceId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TripoApiError) {
|
if (error instanceof TripoApiError) {
|
||||||
|
const shouldRefundMissingTask =
|
||||||
|
error.code === 2001 || /task\s+is\s+not\s+found/i.test(error.message);
|
||||||
|
const refundedPoints =
|
||||||
|
shouldRefundMissingTask && refundContext
|
||||||
|
? await refundModel3DPointsForFailedTask({
|
||||||
|
...refundContext,
|
||||||
|
errorMessage: error.message,
|
||||||
|
}).catch((refundError) => {
|
||||||
|
console.error(
|
||||||
|
"[Points] Failed to refund missing 3D task:",
|
||||||
|
refundError
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
@ -314,6 +428,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
code: error.code,
|
code: error.code,
|
||||||
suggestion: error.suggestion,
|
suggestion: error.suggestion,
|
||||||
traceId: error.traceId,
|
traceId: error.traceId,
|
||||||
|
refundedPoints,
|
||||||
},
|
},
|
||||||
{ status: error.httpStatus || 502 }
|
{ status: error.httpStatus || 502 }
|
||||||
);
|
);
|
||||||
|
|||||||
@ -336,14 +336,42 @@ export async function POST(request: Request) {
|
|||||||
animateInPlace: "animate_in_place",
|
animateInPlace: "animate_in_place",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const featureVisibility = modelAliasConfig.featureVisibility;
|
||||||
|
const effectivePbr = featureVisibility.pbr !== false && body.pbr === true;
|
||||||
|
const effectiveQuad = featureVisibility.quad !== false && body.quad === true;
|
||||||
|
const effectiveSmartLowPoly =
|
||||||
|
featureVisibility.smartLowPoly !== false && body.smartLowPoly === true;
|
||||||
|
|
||||||
|
if (featureVisibility.pbr === false) {
|
||||||
|
delete payload.pbr;
|
||||||
|
}
|
||||||
|
if (featureVisibility.quad === false) {
|
||||||
|
delete payload.quad;
|
||||||
|
}
|
||||||
|
if (featureVisibility.smartLowPoly === false) {
|
||||||
|
delete payload.smart_low_poly;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
taskType === "text_to_model" ||
|
taskType === "text_to_model" ||
|
||||||
taskType === "image_to_model" ||
|
taskType === "image_to_model" ||
|
||||||
taskType === "multiview_to_model"
|
taskType === "multiview_to_model"
|
||||||
) {
|
) {
|
||||||
|
const enabledTripoModels = modelAliasConfig.tripoModels.filter(
|
||||||
|
(entry) => entry.enabled !== false,
|
||||||
|
);
|
||||||
|
if (enabledTripoModels.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "当前没有启用的 3D 生成模型",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
payload.model_version = resolveThreeDModelRealId(
|
payload.model_version = resolveThreeDModelRealId(
|
||||||
readOptionalTrimmedText(body.modelVersion),
|
readOptionalTrimmedText(body.modelVersion),
|
||||||
modelAliasConfig.tripoModels,
|
enabledTripoModels,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -354,9 +382,9 @@ export async function POST(request: Request) {
|
|||||||
const pointsConfig = await getPointsSystemConfig();
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
const unitCost = get3DTaskCreditCost(pointsConfig, {
|
const unitCost = get3DTaskCreditCost(pointsConfig, {
|
||||||
taskType,
|
taskType,
|
||||||
pbr: body.pbr,
|
pbr: effectivePbr,
|
||||||
quad: body.quad,
|
quad: effectiveQuad,
|
||||||
smartLowPoly: body.smartLowPoly,
|
smartLowPoly: effectiveSmartLowPoly,
|
||||||
});
|
});
|
||||||
const pointsCharge = await prepareFeaturePointsCharge({
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
|||||||
@ -23,6 +23,8 @@ const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
|
|||||||
function mapLedgerReasonLabel(reason: string) {
|
function mapLedgerReasonLabel(reason: string) {
|
||||||
if (reason === "image_generation") return "图片生成";
|
if (reason === "image_generation") return "图片生成";
|
||||||
if (reason === "model3d_generation") return "3D模型生成";
|
if (reason === "model3d_generation") return "3D模型生成";
|
||||||
|
if (reason === "model3d_task") return "3D模型任务";
|
||||||
|
if (reason === "model3d_task_refund") return "3D模型任务失败退回";
|
||||||
if (reason === "world3d_generation") return "3D世界生成";
|
if (reason === "world3d_generation") return "3D世界生成";
|
||||||
if (reason === "video_generation") return "视频生成";
|
if (reason === "video_generation") return "视频生成";
|
||||||
if (reason === "enhance_pro_generation") return "图像增强 Pro";
|
if (reason === "enhance_pro_generation") return "图像增强 Pro";
|
||||||
|
|||||||
@ -824,6 +824,9 @@ export default function CanvasModel3DPage() {
|
|||||||
const isHighVip = membershipSummary?.isHighVip === true;
|
const isHighVip = membershipSummary?.isHighVip === true;
|
||||||
const publicModelAliasConfig =
|
const publicModelAliasConfig =
|
||||||
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
|
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
|
||||||
|
const modelFeatureVisibility =
|
||||||
|
publicModelAliasConfig.featureVisibility ||
|
||||||
|
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.featureVisibility;
|
||||||
const selectedBatchImageCount = useMemo(
|
const selectedBatchImageCount = useMemo(
|
||||||
() => batchImageSlots.filter((slot) => Boolean(slot.file)).length,
|
() => batchImageSlots.filter((slot) => Boolean(slot.file)).length,
|
||||||
[batchImageSlots]
|
[batchImageSlots]
|
||||||
@ -833,11 +836,21 @@ export default function CanvasModel3DPage() {
|
|||||||
() =>
|
() =>
|
||||||
get3DTaskCreditCost(pointsSystemConfig, {
|
get3DTaskCreditCost(pointsSystemConfig, {
|
||||||
taskType: currentModelTaskType,
|
taskType: currentModelTaskType,
|
||||||
pbr,
|
pbr: modelFeatureVisibility.pbr !== false && pbr,
|
||||||
quad,
|
quad: modelFeatureVisibility.quad !== false && quad,
|
||||||
smartLowPoly,
|
smartLowPoly:
|
||||||
|
modelFeatureVisibility.smartLowPoly !== false && smartLowPoly,
|
||||||
}),
|
}),
|
||||||
[currentModelTaskType, pbr, pointsSystemConfig, quad, smartLowPoly]
|
[
|
||||||
|
currentModelTaskType,
|
||||||
|
modelFeatureVisibility.pbr,
|
||||||
|
modelFeatureVisibility.quad,
|
||||||
|
modelFeatureVisibility.smartLowPoly,
|
||||||
|
pbr,
|
||||||
|
pointsSystemConfig,
|
||||||
|
quad,
|
||||||
|
smartLowPoly,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
const currentModelEstimatedCost =
|
const currentModelEstimatedCost =
|
||||||
mode === "batch" && selectedBatchImageCount > 0
|
mode === "batch" && selectedBatchImageCount > 0
|
||||||
@ -941,23 +954,17 @@ export default function CanvasModel3DPage() {
|
|||||||
siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo;
|
siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo;
|
||||||
const tripoModelOptions = useMemo(
|
const tripoModelOptions = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const enabledEntries = publicModelAliasConfig.tripoModels.filter(
|
return publicModelAliasConfig.tripoModels.filter(
|
||||||
(item) => item.enabled !== false,
|
(item) => item.enabled !== false,
|
||||||
);
|
);
|
||||||
return enabledEntries.length > 0
|
|
||||||
? enabledEntries
|
|
||||||
: publicModelAliasConfig.tripoModels;
|
|
||||||
},
|
},
|
||||||
[publicModelAliasConfig.tripoModels],
|
[publicModelAliasConfig.tripoModels],
|
||||||
);
|
);
|
||||||
const worldModelOptions = useMemo(
|
const worldModelOptions = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const enabledEntries = publicModelAliasConfig.worldModels.filter(
|
return publicModelAliasConfig.worldModels.filter(
|
||||||
(item) => item.enabled !== false,
|
(item) => item.enabled !== false,
|
||||||
);
|
);
|
||||||
return enabledEntries.length > 0
|
|
||||||
? enabledEntries
|
|
||||||
: publicModelAliasConfig.worldModels;
|
|
||||||
},
|
},
|
||||||
[publicModelAliasConfig.worldModels],
|
[publicModelAliasConfig.worldModels],
|
||||||
);
|
);
|
||||||
@ -1529,11 +1536,12 @@ export default function CanvasModel3DPage() {
|
|||||||
modelVersion,
|
modelVersion,
|
||||||
faceLimit,
|
faceLimit,
|
||||||
texture,
|
texture,
|
||||||
pbr,
|
pbr: modelFeatureVisibility.pbr !== false && pbr,
|
||||||
autoSize,
|
autoSize,
|
||||||
quad,
|
quad: modelFeatureVisibility.quad !== false && quad,
|
||||||
generateParts,
|
generateParts,
|
||||||
smartLowPoly,
|
smartLowPoly:
|
||||||
|
modelFeatureVisibility.smartLowPoly !== false && smartLowPoly,
|
||||||
geometryQuality,
|
geometryQuality,
|
||||||
textureQuality,
|
textureQuality,
|
||||||
modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined,
|
modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined,
|
||||||
@ -3028,47 +3036,57 @@ export default function CanvasModel3DPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 rounded-lg border border-white/10 bg-zinc-950 p-3">
|
{modelFeatureVisibility.pbr !== false ||
|
||||||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
modelFeatureVisibility.quad !== false ||
|
||||||
<span className="flex items-center gap-2">
|
modelFeatureVisibility.smartLowPoly !== false ? (
|
||||||
PBR 材质
|
<div className="space-y-3 rounded-lg border border-white/10 bg-zinc-950 p-3">
|
||||||
<CostPill label={pbrExtraCostLabel} />
|
{modelFeatureVisibility.pbr !== false ? (
|
||||||
</span>
|
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||||||
<Switch
|
<span className="flex items-center gap-2">
|
||||||
checked={pbr}
|
PBR 材质
|
||||||
className="data-[state=checked]:bg-yellow-500"
|
<CostPill label={pbrExtraCostLabel} />
|
||||||
onCheckedChange={setPbr}
|
</span>
|
||||||
/>
|
<Switch
|
||||||
|
checked={pbr}
|
||||||
|
className="data-[state=checked]:bg-yellow-500"
|
||||||
|
onCheckedChange={setPbr}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{modelFeatureVisibility.quad !== false ? (
|
||||||
|
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
四边面优化
|
||||||
|
<CostPill label={quadExtraCostLabel} />
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={quad}
|
||||||
|
className="data-[state=checked]:bg-yellow-500"
|
||||||
|
onCheckedChange={setQuad}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{modelFeatureVisibility.smartLowPoly !== false ? (
|
||||||
|
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
智能低模
|
||||||
|
<CostPill label={smartLowPolyExtraCostLabel} />
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={smartLowPoly}
|
||||||
|
className="data-[state=checked]:bg-yellow-500"
|
||||||
|
onCheckedChange={setSmartLowPoly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
) : null}
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
四边面优化
|
|
||||||
<CostPill label={quadExtraCostLabel} />
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
checked={quad}
|
|
||||||
className="data-[state=checked]:bg-yellow-500"
|
|
||||||
onCheckedChange={setQuad}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
智能低模
|
|
||||||
<CostPill label={smartLowPolyExtraCostLabel} />
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
checked={smartLowPoly}
|
|
||||||
className="data-[state=checked]:bg-yellow-500"
|
|
||||||
onCheckedChange={setSmartLowPoly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full bg-yellow-500 text-black hover:bg-yellow-400"
|
className="w-full bg-yellow-500 text-black hover:bg-yellow-400"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || tripoModelOptions.length === 0}
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@ -3079,7 +3097,9 @@ export default function CanvasModel3DPage() {
|
|||||||
)}
|
)}
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? "生成中..."
|
? "生成中..."
|
||||||
: `开始生成 · ${modelFeatureCostLabel}`}
|
: tripoModelOptions.length === 0
|
||||||
|
? "暂无可用模型"
|
||||||
|
: `开始生成 · ${modelFeatureCostLabel}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -9,9 +9,16 @@ export type ThreeDModelAliasEntry = {
|
|||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ThreeDModelFeatureVisibilityConfig = {
|
||||||
|
pbr: boolean;
|
||||||
|
quad: boolean;
|
||||||
|
smartLowPoly: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ThreeDModelAliasConfig = {
|
export type ThreeDModelAliasConfig = {
|
||||||
tripoModels: ThreeDModelAliasEntry[];
|
tripoModels: ThreeDModelAliasEntry[];
|
||||||
worldModels: ThreeDModelAliasEntry[];
|
worldModels: ThreeDModelAliasEntry[];
|
||||||
|
featureVisibility: ThreeDModelFeatureVisibilityConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PublicThreeDModelAliasEntry = Omit<
|
export type PublicThreeDModelAliasEntry = Omit<
|
||||||
@ -24,6 +31,7 @@ export type PublicThreeDModelAliasEntry = Omit<
|
|||||||
export type PublicThreeDModelAliasConfig = {
|
export type PublicThreeDModelAliasConfig = {
|
||||||
tripoModels: PublicThreeDModelAliasEntry[];
|
tripoModels: PublicThreeDModelAliasEntry[];
|
||||||
worldModels: PublicThreeDModelAliasEntry[];
|
worldModels: PublicThreeDModelAliasEntry[];
|
||||||
|
featureVisibility: ThreeDModelFeatureVisibilityConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_TRIPO_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
|
const DEFAULT_TRIPO_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
|
||||||
@ -112,6 +120,12 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_THREED_MODEL_FEATURE_VISIBILITY: ThreeDModelFeatureVisibilityConfig = {
|
||||||
|
pbr: true,
|
||||||
|
quad: true,
|
||||||
|
smartLowPoly: true,
|
||||||
|
};
|
||||||
|
|
||||||
function sanitizeText(value: unknown) {
|
function sanitizeText(value: unknown) {
|
||||||
return String(value || "").trim();
|
return String(value || "").trim();
|
||||||
}
|
}
|
||||||
@ -196,10 +210,35 @@ function normalizeEntryList(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFeatureVisibility(
|
||||||
|
value: unknown,
|
||||||
|
): ThreeDModelFeatureVisibilityConfig {
|
||||||
|
const raw =
|
||||||
|
value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Partial<ThreeDModelFeatureVisibilityConfig>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pbr:
|
||||||
|
typeof raw.pbr === "boolean"
|
||||||
|
? raw.pbr
|
||||||
|
: DEFAULT_THREED_MODEL_FEATURE_VISIBILITY.pbr,
|
||||||
|
quad:
|
||||||
|
typeof raw.quad === "boolean"
|
||||||
|
? raw.quad
|
||||||
|
: DEFAULT_THREED_MODEL_FEATURE_VISIBILITY.quad,
|
||||||
|
smartLowPoly:
|
||||||
|
typeof raw.smartLowPoly === "boolean"
|
||||||
|
? raw.smartLowPoly
|
||||||
|
: DEFAULT_THREED_MODEL_FEATURE_VISIBILITY.smartLowPoly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function defaultThreeDModelAliasConfig(): ThreeDModelAliasConfig {
|
export function defaultThreeDModelAliasConfig(): ThreeDModelAliasConfig {
|
||||||
return {
|
return {
|
||||||
tripoModels: cloneEntries(DEFAULT_TRIPO_MODEL_ALIASES),
|
tripoModels: cloneEntries(DEFAULT_TRIPO_MODEL_ALIASES),
|
||||||
worldModels: cloneEntries(DEFAULT_WORLD_MODEL_ALIASES),
|
worldModels: cloneEntries(DEFAULT_WORLD_MODEL_ALIASES),
|
||||||
|
featureVisibility: { ...DEFAULT_THREED_MODEL_FEATURE_VISIBILITY },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,6 +261,7 @@ export function normalizeThreeDModelAliasConfig(
|
|||||||
DEFAULT_WORLD_MODEL_ALIASES,
|
DEFAULT_WORLD_MODEL_ALIASES,
|
||||||
"world-model",
|
"world-model",
|
||||||
),
|
),
|
||||||
|
featureVisibility: normalizeFeatureVisibility(raw.featureVisibility),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -236,6 +276,7 @@ export function toPublicThreeDModelAliasConfig(
|
|||||||
...entry,
|
...entry,
|
||||||
billingModelId: realModelId,
|
billingModelId: realModelId,
|
||||||
})),
|
})),
|
||||||
|
featureVisibility: normalizeFeatureVisibility(config.featureVisibility),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user