Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-mainline
This commit is contained in:
commit
9b5dbbe856
@ -0,0 +1,121 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { AppError, ErrorCode } from "@/server/core/errors";
|
||||
import { handleRequest, jsonError } from "@/server/core/response";
|
||||
import { intakeNonIntegratedProductService } from "@/server/services/intake-non-integrated-product.service";
|
||||
import {
|
||||
createIntakeProductFactoryContractDocx,
|
||||
normalizeProductFactoryContractBatch,
|
||||
normalizeProductFactoryContractType,
|
||||
} from "@/server/services/intake-contract-docx.service";
|
||||
import { isIntakeNonIntegratedSupplyChainUser } from "@/lib/non-integrated-permissions";
|
||||
import type { AuthUser } from "@/server/core/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function canDownloadProductFactoryContract(user: AuthUser) {
|
||||
if (user.role === "ADMIN" || user.role === "SUPER_ADMIN") return true;
|
||||
|
||||
return isIntakeNonIntegratedSupplyChainUser({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
businessRoles: user.businessRoles,
|
||||
department: user.department,
|
||||
departmentPaths: user.departmentPaths,
|
||||
});
|
||||
}
|
||||
|
||||
function buildContentDisposition(fileName: string) {
|
||||
const fallbackName = fileName.replace(/[^\x20-\x7E]+/g, "_");
|
||||
return `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(
|
||||
fileName,
|
||||
)}`;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
if (!canDownloadProductFactoryContract(ctx.user)) {
|
||||
return jsonError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
"当前账号没有下载产品定厂合同的权限",
|
||||
403,
|
||||
ctx.requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const productId = decodeURIComponent(id);
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const contractType = normalizeProductFactoryContractType(
|
||||
searchParams.get("type"),
|
||||
);
|
||||
const batch = normalizeProductFactoryContractBatch(
|
||||
searchParams.get("batch"),
|
||||
);
|
||||
|
||||
if (!contractType) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
"合同类型不正确",
|
||||
400,
|
||||
ctx.requestId,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const product = await intakeNonIntegratedProductService.getById(
|
||||
productId,
|
||||
ctx.user,
|
||||
);
|
||||
if (!product) {
|
||||
return jsonError(
|
||||
ErrorCode.PRODUCT_NOT_FOUND,
|
||||
"非集成产品不存在",
|
||||
404,
|
||||
ctx.requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const contract = await createIntakeProductFactoryContractDocx({
|
||||
product,
|
||||
contractType,
|
||||
batch,
|
||||
});
|
||||
|
||||
const contractArrayBuffer = new ArrayBuffer(contract.bytes.byteLength);
|
||||
new Uint8Array(contractArrayBuffer).set(contract.bytes);
|
||||
const contractBlob = new Blob([contractArrayBuffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
});
|
||||
|
||||
return new NextResponse(contractBlob, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"Content-Disposition": buildContentDisposition(contract.fileName),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Request-Id": ctx.requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(
|
||||
error.code,
|
||||
error.message,
|
||||
error.statusCode,
|
||||
ctx.requestId,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
);
|
||||
}
|
||||
@ -1161,6 +1161,34 @@
|
||||
background: rgba(45, 51, 56, 0.25);
|
||||
}
|
||||
|
||||
.non-integrated-table-scroll {
|
||||
overflow-x: scroll !important;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 12px;
|
||||
scrollbar-color: rgba(45, 51, 56, 0.34) rgba(45, 51, 56, 0.08);
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
.non-integrated-table-scroll::-webkit-scrollbar {
|
||||
display: block;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.non-integrated-table-scroll::-webkit-scrollbar-track {
|
||||
background: rgba(45, 51, 56, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.non-integrated-table-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(45, 51, 56, 0.32);
|
||||
border: 3px solid rgba(45, 51, 56, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.non-integrated-table-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(45, 51, 56, 0.48);
|
||||
}
|
||||
|
||||
[data-theme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(220, 227, 233, 0.15);
|
||||
}
|
||||
@ -1168,6 +1196,25 @@
|
||||
[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(220, 227, 233, 0.25);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .non-integrated-table-scroll {
|
||||
scrollbar-color: rgba(220, 227, 233, 0.36)
|
||||
rgba(220, 227, 233, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .non-integrated-table-scroll::-webkit-scrollbar-track {
|
||||
background: rgba(220, 227, 233, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .non-integrated-table-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(220, 227, 233, 0.36);
|
||||
border: 3px solid rgba(220, 227, 233, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='dark']
|
||||
.non-integrated-table-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(220, 227, 233, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* 3D 相关样式 */
|
||||
|
||||
@ -9573,6 +9573,7 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
seriesNameOptions={seriesNameOptions}
|
||||
existingSeriesNames={existingSeriesNames}
|
||||
seriesDraftSkuOptions={seriesDraftSkuOptions}
|
||||
materialLibraryFinishes={props.materialLibraryFinishes}
|
||||
initialDocumentWorkspaceView={activeWorkspaceTab.detailState?.view}
|
||||
initialDocumentWorkspaceCategory={activeWorkspaceTab.detailState?.category}
|
||||
currentUserRole={props.user.role}
|
||||
@ -9649,6 +9650,7 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
seriesNameOptions={seriesNameOptions}
|
||||
existingSeriesNames={existingSeriesNames}
|
||||
seriesDraftSkuOptions={seriesDraftSkuOptions}
|
||||
materialLibraryFinishes={props.materialLibraryFinishes}
|
||||
currentUserRole={props.user.role}
|
||||
currentUserId={props.user.id}
|
||||
currentUserName={props.user.name}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service'
|
||||
import { materialService } from '@/server/services/material.service'
|
||||
import { canEditNonIntegratedProductIdentity } from '@/lib/non-integrated-permissions'
|
||||
import NonIntegratedDetailClient from '../NonIntegratedDetailClient'
|
||||
|
||||
@ -27,7 +28,10 @@ export default async function NonIntegratedDetailPage({
|
||||
]
|
||||
: undefined
|
||||
const decodedId = decodeURIComponent(id)
|
||||
const initialProduct = await intakeNonIntegratedProductService.getById(decodedId, authUser)
|
||||
const [initialProduct, materialLibraryFinishes] = await Promise.all([
|
||||
intakeNonIntegratedProductService.getById(decodedId, authUser),
|
||||
materialService.findAll({ limit: 500 }),
|
||||
])
|
||||
const linkEntryView = resolvedSearchParams.workspace === 'opening-reference'
|
||||
const canEditProductIdentity =
|
||||
initialProduct &&
|
||||
@ -57,6 +61,7 @@ export default async function NonIntegratedDetailPage({
|
||||
currentUserBusinessRoles={user.businessRoles}
|
||||
currentUserDepartmentName={user.department?.name}
|
||||
currentUserDepartmentPaths={user.departmentPaths}
|
||||
materialLibraryFinishes={materialLibraryFinishes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -58,6 +58,8 @@ function createProduct(overrides?: Partial<NonIntegratedProductRecord>): NonInte
|
||||
productImageName: '',
|
||||
size: '',
|
||||
material: '',
|
||||
barcode: '',
|
||||
reviewResult: '',
|
||||
color: '',
|
||||
issues: [],
|
||||
},
|
||||
|
||||
@ -15,6 +15,10 @@ import {
|
||||
type NonIntegratedBomCategory,
|
||||
type NonIntegratedBomPackageSubcategory,
|
||||
} from "@/lib/non-integrated-bom-schema";
|
||||
import {
|
||||
normalizeAssetVariantUvConfig,
|
||||
type AssetVariantUvConfig,
|
||||
} from "@/lib/asset-variant-render-config";
|
||||
import { normalizeNonIntegratedAssetUrl } from "./nonIntegratedAssetUrl";
|
||||
|
||||
export const NON_INTEGRATED_STORAGE_KEY = "intake.nonIntegratedProducts";
|
||||
@ -361,6 +365,8 @@ export type NonIntegratedSkuItem = {
|
||||
model3dDataUrl: string;
|
||||
model3dNodeOffsets?: Record<string, [number, number, number]>;
|
||||
model3dNodeRotations?: Record<string, [number, number, number, number]>;
|
||||
model3dNodeMaterialIds?: Record<string, string>;
|
||||
model3dUvConfig?: AssetVariantUvConfig | null;
|
||||
componentDetails?: NonIntegratedSkuComponentDetail[];
|
||||
source?: string;
|
||||
sourceTable?: string;
|
||||
@ -392,6 +398,7 @@ export type NonIntegratedFactoryRow = {
|
||||
supplierCode: string;
|
||||
warehouse: string;
|
||||
minimumOrderQuantity: string;
|
||||
taxIncludedUnitPrice: string;
|
||||
leadTime: string;
|
||||
assemblyMode: string;
|
||||
productionType: string;
|
||||
@ -496,6 +503,8 @@ export type NonIntegratedStyleDetail = {
|
||||
model3dDataUrl: string;
|
||||
model3dNodeOffsets?: Record<string, [number, number, number]>;
|
||||
model3dNodeRotations?: Record<string, [number, number, number, number]>;
|
||||
model3dNodeMaterialIds?: Record<string, string>;
|
||||
model3dUvConfig?: AssetVariantUvConfig | null;
|
||||
outerPackagingType?: string;
|
||||
innerPackagingItems?: NonIntegratedPackagingInnerItem[];
|
||||
packagingModel3dFileName?: string;
|
||||
@ -505,6 +514,8 @@ export type NonIntegratedStyleDetail = {
|
||||
string,
|
||||
[number, number, number, number]
|
||||
>;
|
||||
packagingModel3dNodeMaterialIds?: Record<string, string>;
|
||||
packagingModel3dUvConfig?: AssetVariantUvConfig | null;
|
||||
};
|
||||
|
||||
export type NonIntegratedBomRow = {
|
||||
@ -569,6 +580,8 @@ export type NonIntegratedReviewSheet = {
|
||||
productImageName: string;
|
||||
size: string;
|
||||
material: string;
|
||||
barcode: string;
|
||||
reviewResult: string;
|
||||
color: string;
|
||||
issues: NonIntegratedReviewIssue[];
|
||||
};
|
||||
@ -1248,6 +1261,8 @@ function normalizeSkuItem(
|
||||
),
|
||||
model3dNodeOffsets: normalizeTupleMap(item.model3dNodeOffsets, 3),
|
||||
model3dNodeRotations: normalizeTupleMap(item.model3dNodeRotations, 4),
|
||||
model3dNodeMaterialIds: normalizeStringRecord(item.model3dNodeMaterialIds),
|
||||
model3dUvConfig: normalizeAssetVariantUvConfig(item.model3dUvConfig),
|
||||
componentDetails: normalizeNonIntegratedSkuComponentDetails(
|
||||
item.componentDetails,
|
||||
),
|
||||
@ -1284,6 +1299,7 @@ function normalizeFactoryRow(
|
||||
supplierCode: normalizeString(item.supplierCode).toUpperCase(),
|
||||
warehouse: normalizeString(item.warehouse),
|
||||
minimumOrderQuantity: normalizeString(item.minimumOrderQuantity),
|
||||
taxIncludedUnitPrice: normalizeString(item.taxIncludedUnitPrice),
|
||||
leadTime: normalizeString(item.leadTime),
|
||||
assemblyMode: normalizeString(item.assemblyMode),
|
||||
productionType: normalizeString(item.productionType),
|
||||
@ -1583,6 +1599,8 @@ function normalizeStyleDetail(entry: unknown): NonIntegratedStyleDetail | null {
|
||||
),
|
||||
model3dNodeOffsets: normalizeTupleMap(item.model3dNodeOffsets, 3),
|
||||
model3dNodeRotations: normalizeTupleMap(item.model3dNodeRotations, 4),
|
||||
model3dNodeMaterialIds: normalizeStringRecord(item.model3dNodeMaterialIds),
|
||||
model3dUvConfig: normalizeAssetVariantUvConfig(item.model3dUvConfig),
|
||||
outerPackagingType: normalizeString(item.outerPackagingType),
|
||||
innerPackagingItems: Array.isArray(item.innerPackagingItems)
|
||||
? item.innerPackagingItems
|
||||
@ -1604,6 +1622,12 @@ function normalizeStyleDetail(entry: unknown): NonIntegratedStyleDetail | null {
|
||||
item.packagingModel3dNodeRotations,
|
||||
4,
|
||||
),
|
||||
packagingModel3dNodeMaterialIds: normalizeStringRecord(
|
||||
item.packagingModel3dNodeMaterialIds,
|
||||
),
|
||||
packagingModel3dUvConfig: normalizeAssetVariantUvConfig(
|
||||
item.packagingModel3dUvConfig,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1752,6 +1776,8 @@ function createEmptyReviewSheet(): NonIntegratedReviewSheet {
|
||||
productImageName: "",
|
||||
size: "",
|
||||
material: "",
|
||||
barcode: "",
|
||||
reviewResult: "",
|
||||
color: "",
|
||||
issues: [],
|
||||
};
|
||||
@ -1769,6 +1795,8 @@ function normalizeReviewSheet(entry: unknown): NonIntegratedReviewSheet {
|
||||
productImageName: "",
|
||||
size: "",
|
||||
material: "",
|
||||
barcode: "",
|
||||
reviewResult: "",
|
||||
color: "",
|
||||
issues: [],
|
||||
};
|
||||
@ -1787,6 +1815,8 @@ function normalizeReviewSheet(entry: unknown): NonIntegratedReviewSheet {
|
||||
productImageName: normalizeString(item.productImageName),
|
||||
size: normalizeString(item.size),
|
||||
material: normalizeString(item.material),
|
||||
barcode: normalizeString(item.barcode),
|
||||
reviewResult: normalizeString(item.reviewResult),
|
||||
color: normalizeString(item.color),
|
||||
issues: Array.isArray(item.issues)
|
||||
? item.issues
|
||||
@ -2364,6 +2394,10 @@ export function createCopiedNonIntegratedProduct(
|
||||
model3dNodeRotations: detail.model3dNodeRotations
|
||||
? { ...detail.model3dNodeRotations }
|
||||
: {},
|
||||
model3dNodeMaterialIds: detail.model3dNodeMaterialIds
|
||||
? { ...detail.model3dNodeMaterialIds }
|
||||
: {},
|
||||
model3dUvConfig: normalizeAssetVariantUvConfig(detail.model3dUvConfig),
|
||||
innerPackagingItems: (detail.innerPackagingItems ?? []).map((item) => ({
|
||||
...item,
|
||||
})),
|
||||
@ -2373,6 +2407,12 @@ export function createCopiedNonIntegratedProduct(
|
||||
packagingModel3dNodeRotations: detail.packagingModel3dNodeRotations
|
||||
? { ...detail.packagingModel3dNodeRotations }
|
||||
: {},
|
||||
packagingModel3dNodeMaterialIds: detail.packagingModel3dNodeMaterialIds
|
||||
? { ...detail.packagingModel3dNodeMaterialIds }
|
||||
: {},
|
||||
packagingModel3dUvConfig: normalizeAssetVariantUvConfig(
|
||||
detail.packagingModel3dUvConfig,
|
||||
),
|
||||
})),
|
||||
bomDetails: sourceProduct.bomDetails.map((detail) => ({
|
||||
...detail,
|
||||
@ -2428,6 +2468,10 @@ export function createCopiedNonIntegratedProduct(
|
||||
model3dNodeRotations: item.model3dNodeRotations
|
||||
? { ...item.model3dNodeRotations }
|
||||
: {},
|
||||
model3dNodeMaterialIds: item.model3dNodeMaterialIds
|
||||
? { ...item.model3dNodeMaterialIds }
|
||||
: {},
|
||||
model3dUvConfig: normalizeAssetVariantUvConfig(item.model3dUvConfig),
|
||||
};
|
||||
}),
|
||||
factoryRows: [],
|
||||
|
||||
@ -53,6 +53,7 @@ interface ModelViewerProps {
|
||||
semanticPreviewFallbackNodeNames?: string[];
|
||||
selectableNodeNames?: string[];
|
||||
selectedNodeName?: string | null;
|
||||
selectedNodeNames?: string[];
|
||||
onNodeSelect?: (payload: {
|
||||
nodeName: string | null;
|
||||
rawNodeName: string | null;
|
||||
@ -93,6 +94,7 @@ interface ModelViewerProps {
|
||||
clearAlpha?: number;
|
||||
enableTranslateGizmo?: boolean;
|
||||
gizmoMode?: 'translate' | 'rotate';
|
||||
gizmoSize?: number;
|
||||
gizmoAxisColors?: Partial<Record<AxisKey, string>>;
|
||||
onGizmoInteractionStart?: (nodeName: string) => void;
|
||||
onGizmoInteractionEnd?: (nodeName: string) => void;
|
||||
@ -135,6 +137,22 @@ interface TransformFeedbackBaseline {
|
||||
worldQuaternion: THREE.Quaternion;
|
||||
}
|
||||
|
||||
interface TransformGizmoTargetState {
|
||||
nodeName: string | null;
|
||||
nodeNames: string[];
|
||||
object: THREE.Object3D;
|
||||
targetObjects: Array<{
|
||||
nodeName: string;
|
||||
object: THREE.Object3D;
|
||||
baselineTargetMatrixWorld: THREE.Matrix4 | null;
|
||||
}>;
|
||||
anchor: THREE.Object3D;
|
||||
isFallbackModelTarget: boolean;
|
||||
baselineAnchorPosition: THREE.Vector3 | null;
|
||||
baselineAnchorQuaternion: THREE.Quaternion | null;
|
||||
baselineTargetMatrixWorld: THREE.Matrix4 | null;
|
||||
}
|
||||
|
||||
const TRANSFORM_FEEDBACK_AXIS_KEYS: AxisKey[] = ["X", "Y", "Z"];
|
||||
const TRANSFORM_FEEDBACK_AXIS_COLORS: Record<AxisKey, string> = {
|
||||
X: "#8f625d",
|
||||
@ -146,6 +164,152 @@ const TRANSFORM_FEEDBACK_WORLD_AXIS: Record<AxisKey, THREE.Vector3> = {
|
||||
Y: new THREE.Vector3(0, 1, 0),
|
||||
Z: new THREE.Vector3(0, 0, 1),
|
||||
};
|
||||
const MODEL_VIEWER_GIZMO_ANCHOR_NAME = "__model_viewer_gizmo_anchor__";
|
||||
|
||||
function getObjectWorldBoundsCenter(object: THREE.Object3D): THREE.Vector3 {
|
||||
object.updateMatrixWorld(true);
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
if (!box.isEmpty()) {
|
||||
return box.getCenter(new THREE.Vector3());
|
||||
}
|
||||
return object.getWorldPosition(new THREE.Vector3());
|
||||
}
|
||||
|
||||
function getObjectsWorldBoundsCenter(objects: THREE.Object3D[]): THREE.Vector3 {
|
||||
if (objects.length === 0) {
|
||||
return new THREE.Vector3();
|
||||
}
|
||||
|
||||
const box = new THREE.Box3();
|
||||
let hasBounds = false;
|
||||
objects.forEach((object) => {
|
||||
object.updateMatrixWorld(true);
|
||||
const objectBox = new THREE.Box3().setFromObject(object);
|
||||
if (objectBox.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!hasBounds) {
|
||||
box.copy(objectBox);
|
||||
hasBounds = true;
|
||||
return;
|
||||
}
|
||||
box.union(objectBox);
|
||||
});
|
||||
|
||||
if (hasBounds) {
|
||||
return box.getCenter(new THREE.Vector3());
|
||||
}
|
||||
|
||||
return objects[0]?.getWorldPosition(new THREE.Vector3()) ?? new THREE.Vector3();
|
||||
}
|
||||
|
||||
function syncGizmoAnchorToObjectCenter(
|
||||
anchor: THREE.Object3D,
|
||||
object: THREE.Object3D,
|
||||
nodeName: string | null,
|
||||
) {
|
||||
anchor.name = nodeName
|
||||
? `${nodeName}${MODEL_VIEWER_GIZMO_ANCHOR_NAME}`
|
||||
: MODEL_VIEWER_GIZMO_ANCHOR_NAME;
|
||||
anchor.position.copy(getObjectWorldBoundsCenter(object));
|
||||
anchor.quaternion.copy(object.getWorldQuaternion(new THREE.Quaternion()));
|
||||
anchor.scale.set(1, 1, 1);
|
||||
anchor.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
function syncGizmoAnchorToSelectionCenter(
|
||||
anchor: THREE.Object3D,
|
||||
objects: THREE.Object3D[],
|
||||
nodeName: string | null,
|
||||
) {
|
||||
const primaryObject = objects[0];
|
||||
if (!primaryObject) {
|
||||
syncGizmoAnchorToObjectCenter(anchor, new THREE.Object3D(), nodeName);
|
||||
return;
|
||||
}
|
||||
|
||||
anchor.name = nodeName
|
||||
? `${nodeName}${MODEL_VIEWER_GIZMO_ANCHOR_NAME}`
|
||||
: MODEL_VIEWER_GIZMO_ANCHOR_NAME;
|
||||
anchor.position.copy(getObjectsWorldBoundsCenter(objects));
|
||||
anchor.quaternion.copy(primaryObject.getWorldQuaternion(new THREE.Quaternion()));
|
||||
anchor.scale.set(1, 1, 1);
|
||||
anchor.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
function applyGizmoAnchorTransformToTarget(
|
||||
targetState: TransformGizmoTargetState,
|
||||
mode: "translate" | "rotate",
|
||||
) {
|
||||
const {
|
||||
anchor,
|
||||
baselineAnchorPosition,
|
||||
baselineAnchorQuaternion,
|
||||
} = targetState;
|
||||
if (
|
||||
!baselineAnchorPosition ||
|
||||
!baselineAnchorQuaternion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
anchor.updateMatrixWorld(true);
|
||||
const targetObjects =
|
||||
targetState.targetObjects.length > 0
|
||||
? targetState.targetObjects
|
||||
: targetState.nodeName && targetState.baselineTargetMatrixWorld
|
||||
? [
|
||||
{
|
||||
nodeName: targetState.nodeName,
|
||||
object: targetState.object,
|
||||
baselineTargetMatrixWorld: targetState.baselineTargetMatrixWorld,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
targetObjects.forEach((targetObjectState) => {
|
||||
const { object, baselineTargetMatrixWorld } = targetObjectState;
|
||||
if (!baselineTargetMatrixWorld) {
|
||||
return;
|
||||
}
|
||||
|
||||
object.parent?.updateMatrixWorld(true);
|
||||
const nextWorldMatrix = new THREE.Matrix4();
|
||||
|
||||
if (mode === "rotate") {
|
||||
const currentAnchorQuaternion = anchor.getWorldQuaternion(
|
||||
new THREE.Quaternion(),
|
||||
);
|
||||
const deltaQuaternion = currentAnchorQuaternion
|
||||
.clone()
|
||||
.multiply(baselineAnchorQuaternion.clone().invert())
|
||||
.normalize();
|
||||
const pivot = baselineAnchorPosition.clone();
|
||||
const rotateAroundPivotMatrix = new THREE.Matrix4()
|
||||
.makeTranslation(pivot.x, pivot.y, pivot.z)
|
||||
.multiply(new THREE.Matrix4().makeRotationFromQuaternion(deltaQuaternion))
|
||||
.multiply(new THREE.Matrix4().makeTranslation(-pivot.x, -pivot.y, -pivot.z));
|
||||
nextWorldMatrix
|
||||
.copy(rotateAroundPivotMatrix)
|
||||
.multiply(baselineTargetMatrixWorld);
|
||||
} else {
|
||||
const currentAnchorPosition = anchor.getWorldPosition(new THREE.Vector3());
|
||||
const deltaPosition = currentAnchorPosition.sub(baselineAnchorPosition);
|
||||
nextWorldMatrix
|
||||
.makeTranslation(deltaPosition.x, deltaPosition.y, deltaPosition.z)
|
||||
.multiply(baselineTargetMatrixWorld);
|
||||
}
|
||||
|
||||
const parentWorldInverse = object.parent
|
||||
? object.parent.matrixWorld.clone().invert()
|
||||
: new THREE.Matrix4();
|
||||
const nextLocalMatrix = parentWorldInverse.multiply(nextWorldMatrix);
|
||||
nextLocalMatrix.decompose(object.position, object.quaternion, object.scale);
|
||||
object.updateMatrix();
|
||||
object.updateMatrixWorld(true);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAngleRadians(value: number): number {
|
||||
let normalized = value;
|
||||
while (normalized <= -Math.PI) {
|
||||
@ -441,6 +605,14 @@ function createGizmoAxisPalette(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGizmoSize(size: number | null | undefined): number {
|
||||
if (!Number.isFinite(size)) {
|
||||
return 0.72;
|
||||
}
|
||||
|
||||
return THREE.MathUtils.clamp(size ?? 0.72, 0.48, 1.35);
|
||||
}
|
||||
|
||||
function areNumberRecordsEqual(
|
||||
left: ModelExpectedDimensions | null | undefined,
|
||||
right: ModelExpectedDimensions | null | undefined,
|
||||
@ -508,6 +680,7 @@ function ModelViewerComponent({
|
||||
semanticPreviewFallbackNodeNames = [],
|
||||
selectableNodeNames = [],
|
||||
selectedNodeName = null,
|
||||
selectedNodeNames = [],
|
||||
onNodeSelect,
|
||||
onNodeContextMenu,
|
||||
materialAssignments = [],
|
||||
@ -527,6 +700,7 @@ function ModelViewerComponent({
|
||||
clearAlpha = 1,
|
||||
enableTranslateGizmo = false,
|
||||
gizmoMode = 'translate',
|
||||
gizmoSize = 0.72,
|
||||
gizmoAxisColors,
|
||||
onGizmoInteractionStart,
|
||||
onGizmoInteractionEnd,
|
||||
@ -556,8 +730,10 @@ function ModelViewerComponent({
|
||||
);
|
||||
const enableTranslateGizmoRef = useRef(enableTranslateGizmo);
|
||||
const gizmoModeRef = useRef(gizmoMode);
|
||||
const gizmoSizeRef = useRef(resolveGizmoSize(gizmoSize));
|
||||
const gizmoAxisColorsRef = useRef(resolveGizmoAxisColors(gizmoAxisColors));
|
||||
const selectedNodeNameRef = useRef<string | null>(selectedNodeName);
|
||||
const selectedNodeNamesRef = useRef<string[]>(selectedNodeNames);
|
||||
const onLoadRef = useRef<typeof onLoad>(onLoad);
|
||||
const onErrorRef = useRef<typeof onError>(onError);
|
||||
const onEngineReadyRef = useRef<typeof onEngineReady>(onEngineReady);
|
||||
@ -584,9 +760,15 @@ function ModelViewerComponent({
|
||||
null,
|
||||
);
|
||||
const transformFeedbackHideTimerRef = useRef<number | null>(null);
|
||||
const pendingLocalNodePositionOffsetsSyncRef = useRef<
|
||||
NodePositionOffset[] | null
|
||||
>(null);
|
||||
const applyGizmoStyleRef = useRef<(() => void) | null>(null);
|
||||
const syncRotationSnapRef = useRef<(() => void) | null>(null);
|
||||
const syncTransformControlsRef = useRef<(() => void) | null>(null);
|
||||
const transformGizmoTargetRef = useRef<TransformGizmoTargetState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -640,6 +822,10 @@ function ModelViewerComponent({
|
||||
worldFrameMode,
|
||||
],
|
||||
);
|
||||
const selectedNodeNamesSignature = useMemo(
|
||||
() => selectedNodeNames.join("\n"),
|
||||
[selectedNodeNames],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
explodedProgressRef.current = explodedProgress;
|
||||
@ -669,6 +855,10 @@ function ModelViewerComponent({
|
||||
selectedNodeNameRef.current = selectedNodeName;
|
||||
}, [selectedNodeName]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedNodeNamesRef.current = selectedNodeNames;
|
||||
}, [selectedNodeNames]);
|
||||
|
||||
useEffect(() => {
|
||||
worldNodesRef.current = worldNodes;
|
||||
}, [worldNodes]);
|
||||
@ -733,6 +923,11 @@ function ModelViewerComponent({
|
||||
gizmoModeRef.current = gizmoMode;
|
||||
}, [gizmoMode]);
|
||||
|
||||
useEffect(() => {
|
||||
gizmoSizeRef.current = resolveGizmoSize(gizmoSize);
|
||||
transformControlsRef.current?.setSize(gizmoSizeRef.current);
|
||||
}, [gizmoSize]);
|
||||
|
||||
useEffect(() => {
|
||||
gizmoAxisColorsRef.current = resolveGizmoAxisColors(gizmoAxisColors);
|
||||
applyGizmoStyleRef.current?.();
|
||||
@ -1010,11 +1205,16 @@ function ModelViewerComponent({
|
||||
|
||||
const mergedHighlightedParts = useMemo(() => {
|
||||
const parts = new Set(highlightedParts);
|
||||
selectedNodeNames.forEach((nodeName) => {
|
||||
if (nodeName) {
|
||||
parts.add(nodeName);
|
||||
}
|
||||
});
|
||||
if (selectedNodeName) {
|
||||
parts.add(selectedNodeName);
|
||||
}
|
||||
return Array.from(parts);
|
||||
}, [highlightedParts, selectedNodeName]);
|
||||
}, [highlightedParts, selectedNodeName, selectedNodeNames]);
|
||||
|
||||
const loadScene = useCallback(async () => {
|
||||
const sessionId = ++loadSessionRef.current;
|
||||
@ -1243,10 +1443,31 @@ function ModelViewerComponent({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
const hasNodePositionSync =
|
||||
nodePositionOffsets.length > 0 ||
|
||||
semanticPreviewFallbackNodeNames.length > 0
|
||||
) {
|
||||
semanticPreviewFallbackNodeNames.length > 0;
|
||||
|
||||
const pendingLocalNodePositionOffsets =
|
||||
pendingLocalNodePositionOffsetsSyncRef.current;
|
||||
|
||||
if (pendingLocalNodePositionOffsets && !hasNodePositionSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingLocalNodePositionOffsets) {
|
||||
pendingLocalNodePositionOffsetsSyncRef.current = null;
|
||||
if (
|
||||
semanticPreviewFallbackNodeNames.length === 0 &&
|
||||
areNodePositionOffsetsEqual(
|
||||
pendingLocalNodePositionOffsets,
|
||||
nodePositionOffsets,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNodePositionSync) {
|
||||
engineRef.current.setNodePositionOffsets(
|
||||
nodePositionOffsets,
|
||||
semanticPreviewFallbackNodeNames,
|
||||
@ -1325,7 +1546,7 @@ function ModelViewerComponent({
|
||||
);
|
||||
const transformHelper = transformControls.getHelper();
|
||||
transformControls.setSpace("world");
|
||||
transformControls.setSize(0.72);
|
||||
transformControls.setSize(gizmoSizeRef.current);
|
||||
transformHelper.visible = false;
|
||||
const longGuideHelpers: THREE.Object3D[] = [];
|
||||
transformHelper.traverse((obj) => {
|
||||
@ -1404,6 +1625,10 @@ function ModelViewerComponent({
|
||||
});
|
||||
const centerDot = new THREE.Mesh(centerGeo, centerMat);
|
||||
transformHelper.add(centerDot);
|
||||
const transformAnchor = new THREE.Object3D();
|
||||
transformAnchor.name = MODEL_VIEWER_GIZMO_ANCHOR_NAME;
|
||||
transformAnchor.matrixAutoUpdate = true;
|
||||
engineRef.current.getScene().add(transformAnchor);
|
||||
|
||||
const applyRotationSnap = () => {
|
||||
const currentTransformControls = transformControlsRef.current;
|
||||
@ -1432,25 +1657,44 @@ function ModelViewerComponent({
|
||||
applyRotationSnap();
|
||||
|
||||
// hover 时重绘样式(Three.js 会覆盖 material color)
|
||||
const resolveActiveNodeName = () => selectedNodeNameRef.current;
|
||||
const resolveActiveTargetState = () => transformGizmoTargetRef.current;
|
||||
const resolveActiveNodeName = () =>
|
||||
transformGizmoTargetRef.current?.nodeName ?? null;
|
||||
const beginInteraction = () => {
|
||||
const activeNodeName = resolveActiveNodeName();
|
||||
const activeTargetState = resolveActiveTargetState();
|
||||
const activeNodeName = activeTargetState?.nodeName ?? null;
|
||||
const engine = engineRef.current;
|
||||
if (!activeNodeName || !engine || transformInteractionActiveRef.current) {
|
||||
if (
|
||||
!activeTargetState ||
|
||||
!activeNodeName ||
|
||||
!engine ||
|
||||
transformInteractionActiveRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const object = engine.resolveObjectByNodeName(activeNodeName);
|
||||
const object = activeTargetState.object;
|
||||
if (!object) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTransformFeedbackHideTimer();
|
||||
activeTargetState.anchor.updateMatrixWorld(true);
|
||||
object.updateMatrixWorld(true);
|
||||
activeTargetState.baselineAnchorPosition =
|
||||
activeTargetState.anchor.getWorldPosition(new THREE.Vector3());
|
||||
activeTargetState.baselineAnchorQuaternion =
|
||||
activeTargetState.anchor.getWorldQuaternion(new THREE.Quaternion());
|
||||
activeTargetState.baselineTargetMatrixWorld = object.matrixWorld.clone();
|
||||
activeTargetState.targetObjects.forEach((targetObjectState) => {
|
||||
targetObjectState.object.updateMatrixWorld(true);
|
||||
targetObjectState.baselineTargetMatrixWorld =
|
||||
targetObjectState.object.matrixWorld.clone();
|
||||
});
|
||||
transformFeedbackBaselineRef.current = {
|
||||
nodeName: activeNodeName,
|
||||
mode: gizmoModeRef.current,
|
||||
worldPosition: object.getWorldPosition(new THREE.Vector3()),
|
||||
worldPosition: activeTargetState.baselineAnchorPosition.clone(),
|
||||
worldQuaternion: object.getWorldQuaternion(new THREE.Quaternion()),
|
||||
};
|
||||
setTransformFeedbackLabels([]);
|
||||
@ -1470,6 +1714,12 @@ function ModelViewerComponent({
|
||||
}
|
||||
|
||||
transformInteractionActiveRef.current = false;
|
||||
const activeTargetState = resolveActiveTargetState();
|
||||
if (activeTargetState) {
|
||||
activeTargetState.baselineAnchorPosition = null;
|
||||
activeTargetState.baselineAnchorQuaternion = null;
|
||||
activeTargetState.baselineTargetMatrixWorld = null;
|
||||
}
|
||||
const feedbackLabels = buildTransformFeedbackLabels(activeNodeName);
|
||||
const onGizmoInteractionEnd = onGizmoInteractionEndRef.current;
|
||||
if (onGizmoInteractionEnd || feedbackLabels.length > 0) {
|
||||
@ -1504,12 +1754,15 @@ function ModelViewerComponent({
|
||||
endInteraction();
|
||||
};
|
||||
const handleObjectChange = () => {
|
||||
const activeNodeName = resolveActiveNodeName();
|
||||
if (!activeNodeName) return;
|
||||
const activeTargetState = resolveActiveTargetState();
|
||||
const activeNodeName = activeTargetState?.nodeName ?? null;
|
||||
if (!activeTargetState || !activeNodeName) return;
|
||||
beginInteraction();
|
||||
const targetObject = engineRef.current?.resolveObjectByNodeName(
|
||||
activeNodeName,
|
||||
applyGizmoAnchorTransformToTarget(
|
||||
activeTargetState,
|
||||
gizmoModeRef.current,
|
||||
);
|
||||
const targetObject = activeTargetState.object;
|
||||
if (!targetObject) {
|
||||
return;
|
||||
}
|
||||
@ -1572,17 +1825,56 @@ function ModelViewerComponent({
|
||||
}
|
||||
const feedbackLabels = buildTransformFeedbackLabels(activeNodeName);
|
||||
|
||||
if (offset && onNodePositionOffsetChange) {
|
||||
onNodePositionOffsetChange({
|
||||
nodeName: activeNodeName,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
if (rotation && onNodeRotationChange) {
|
||||
onNodeRotationChange({
|
||||
nodeName: activeNodeName,
|
||||
rotation,
|
||||
});
|
||||
const transformedTargets =
|
||||
activeTargetState.targetObjects.length > 0
|
||||
? activeTargetState.targetObjects
|
||||
: [{ nodeName: activeNodeName, object: targetObject }];
|
||||
const emittedNodePositionOffsets: NodePositionOffset[] = [];
|
||||
transformedTargets.forEach(({ nodeName, object }) => {
|
||||
if (isWorldMode && onWorldNodeTransformChange && nodeName !== activeNodeName) {
|
||||
const nextTransform = onWorldNodeTransformChange({
|
||||
nodeName,
|
||||
position: [object.position.x, object.position.y, object.position.z],
|
||||
rotation: [object.rotation.x, object.rotation.y, object.rotation.z],
|
||||
});
|
||||
if (nextTransform?.position) {
|
||||
object.position.set(
|
||||
nextTransform.position[0],
|
||||
nextTransform.position[1],
|
||||
nextTransform.position[2],
|
||||
);
|
||||
}
|
||||
if (nextTransform?.rotation) {
|
||||
object.rotation.set(
|
||||
nextTransform.rotation[0],
|
||||
nextTransform.rotation[1],
|
||||
nextTransform.rotation[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nextOffset = engineRef.current?.getNodeWorldOffset(nodeName);
|
||||
const nextRotation = engineRef.current?.getNodeLocalRotation(nodeName);
|
||||
if (nextOffset && onNodePositionOffsetChange) {
|
||||
emittedNodePositionOffsets.push({
|
||||
nodeName,
|
||||
offset: nextOffset,
|
||||
});
|
||||
onNodePositionOffsetChange({
|
||||
nodeName,
|
||||
offset: nextOffset,
|
||||
});
|
||||
}
|
||||
if (nextRotation && onNodeRotationChange) {
|
||||
onNodeRotationChange({
|
||||
nodeName,
|
||||
rotation: nextRotation,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!isWorldMode && emittedNodePositionOffsets.length > 0) {
|
||||
pendingLocalNodePositionOffsetsSyncRef.current =
|
||||
emittedNodePositionOffsets;
|
||||
}
|
||||
setTransformFeedbackLabels(feedbackLabels);
|
||||
};
|
||||
@ -1596,33 +1888,111 @@ function ModelViewerComponent({
|
||||
}
|
||||
|
||||
currentTransformControls.setMode(gizmoModeRef.current);
|
||||
currentTransformControls.setSize(gizmoSizeRef.current);
|
||||
syncRotationSnapRef.current?.();
|
||||
applyGizmoStyleRef.current?.();
|
||||
|
||||
if (!enableTranslateGizmoRef.current || !selectedNodeNameRef.current) {
|
||||
if (!enableTranslateGizmoRef.current) {
|
||||
currentTransformControls.detach();
|
||||
currentHelper.visible = false;
|
||||
transformGizmoTargetRef.current = null;
|
||||
transformInteractionActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const activeNodeName = selectedNodeNameRef.current;
|
||||
const targetObject = currentEngine.resolveObjectByNodeName(activeNodeName);
|
||||
const previousTargetState = transformGizmoTargetRef.current;
|
||||
const previousTargetBaselineByKey = new Map(
|
||||
previousTargetState?.targetObjects.map((targetObjectState) => [
|
||||
`${targetObjectState.nodeName}\u0000${targetObjectState.object.uuid}`,
|
||||
targetObjectState.baselineTargetMatrixWorld,
|
||||
]) ?? [],
|
||||
);
|
||||
const activeNodeNames = Array.from(
|
||||
new Set(
|
||||
(selectedNodeNamesRef.current.length > 0
|
||||
? selectedNodeNamesRef.current
|
||||
: selectedNodeNameRef.current
|
||||
? [selectedNodeNameRef.current]
|
||||
: []
|
||||
)
|
||||
.map((nodeName) => nodeName.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const activeNodeName = selectedNodeNameRef.current ?? activeNodeNames[0] ?? null;
|
||||
const targetObjects: TransformGizmoTargetState["targetObjects"] = activeNodeNames
|
||||
.map((nodeName) => {
|
||||
const object = currentEngine.resolveObjectByNodeName(nodeName);
|
||||
return object
|
||||
? {
|
||||
nodeName,
|
||||
object,
|
||||
baselineTargetMatrixWorld:
|
||||
previousTargetBaselineByKey.get(
|
||||
`${nodeName}\u0000${object.uuid}`,
|
||||
) ?? null,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is TransformGizmoTargetState["targetObjects"][number] =>
|
||||
Boolean(item),
|
||||
);
|
||||
const targetObject =
|
||||
targetObjects[0]?.object ??
|
||||
(activeNodeName
|
||||
? currentEngine.resolveObjectByNodeName(activeNodeName)
|
||||
: currentEngine.getModelRoot());
|
||||
|
||||
if (!targetObject) {
|
||||
currentTransformControls.detach();
|
||||
currentHelper.visible = false;
|
||||
transformGizmoTargetRef.current = null;
|
||||
transformInteractionActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transformInteractionActiveRef.current) {
|
||||
syncGizmoAnchorToSelectionCenter(
|
||||
transformAnchor,
|
||||
targetObjects.length > 0 ? targetObjects.map((item) => item.object) : [targetObject],
|
||||
activeNodeName,
|
||||
);
|
||||
}
|
||||
const isSamePrimaryTarget =
|
||||
previousTargetState?.anchor === transformAnchor &&
|
||||
previousTargetState.object.uuid === targetObject.uuid;
|
||||
transformGizmoTargetRef.current = {
|
||||
nodeName: activeNodeName,
|
||||
nodeNames: targetObjects.map((item) => item.nodeName),
|
||||
object: targetObject,
|
||||
targetObjects,
|
||||
anchor: transformAnchor,
|
||||
isFallbackModelTarget: !activeNodeName,
|
||||
baselineAnchorPosition:
|
||||
previousTargetState && isSamePrimaryTarget
|
||||
? previousTargetState.baselineAnchorPosition
|
||||
: null,
|
||||
baselineAnchorQuaternion:
|
||||
previousTargetState && isSamePrimaryTarget
|
||||
? previousTargetState.baselineAnchorQuaternion
|
||||
: null,
|
||||
baselineTargetMatrixWorld:
|
||||
previousTargetState && isSamePrimaryTarget
|
||||
? previousTargetState.baselineTargetMatrixWorld
|
||||
: null,
|
||||
};
|
||||
|
||||
currentTransformControls.enabled = true;
|
||||
currentTransformControls.showX = true;
|
||||
currentTransformControls.showY = true;
|
||||
currentTransformControls.showZ = true;
|
||||
targetObject.updateMatrixWorld(true);
|
||||
if (currentTransformControls.object?.uuid !== targetObject.uuid) {
|
||||
currentTransformControls.attach(targetObject);
|
||||
currentTransformControls.enabled = targetObjects.length > 0;
|
||||
transformAnchor.updateMatrixWorld(true);
|
||||
if (currentTransformControls.object?.uuid !== transformAnchor.uuid) {
|
||||
currentTransformControls.attach(transformAnchor);
|
||||
}
|
||||
currentHelper.visible = true;
|
||||
currentHelper.updateMatrixWorld(true);
|
||||
@ -1655,15 +2025,17 @@ function ModelViewerComponent({
|
||||
transformControls.detach();
|
||||
transformHelper.visible = false;
|
||||
engineRef.current?.getScene().remove(transformHelper);
|
||||
engineRef.current?.getScene().remove(transformAnchor);
|
||||
transformControls.dispose();
|
||||
transformControlsRef.current = null;
|
||||
transformHelperRef.current = null;
|
||||
transformGizmoTargetRef.current = null;
|
||||
};
|
||||
}, [engineInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
syncTransformControlsRef.current?.();
|
||||
if (!selectedNodeName) {
|
||||
if (!selectedNodeName && selectedNodeNames.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -1674,14 +2046,29 @@ function ModelViewerComponent({
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
}, [enableTranslateGizmo, engineInstance, gizmoMode, selectedNodeName, worldNodeIdentitySignature]);
|
||||
}, [
|
||||
enableTranslateGizmo,
|
||||
engineInstance,
|
||||
gizmoSize,
|
||||
gizmoMode,
|
||||
selectedNodeName,
|
||||
selectedNodeNames.length,
|
||||
selectedNodeNamesSignature,
|
||||
worldNodeIdentitySignature,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (transformInteractionActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
hideTransformFeedback();
|
||||
}, [enableTranslateGizmo, gizmoMode, hideTransformFeedback, selectedNodeName]);
|
||||
}, [
|
||||
enableTranslateGizmo,
|
||||
gizmoMode,
|
||||
hideTransformFeedback,
|
||||
selectedNodeName,
|
||||
selectedNodeNamesSignature,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateFreeRotateModifier = (pressed: boolean) => {
|
||||
@ -2036,7 +2423,7 @@ function ModelViewerComponent({
|
||||
emitContextMenuSelection({
|
||||
clientX: pickClientX,
|
||||
clientY: pickClientY,
|
||||
appendToSelection: event.ctrlKey || event.metaKey,
|
||||
appendToSelection: event.ctrlKey || event.metaKey || event.shiftKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -2124,7 +2511,7 @@ function ModelViewerComponent({
|
||||
nodeName: selectionNode.nodeName,
|
||||
rawNodeName: selectionNode.rawNodeName,
|
||||
candidateNodeName: selectionNode.candidateNodeName,
|
||||
appendToSelection: event.ctrlKey || event.metaKey,
|
||||
appendToSelection: event.ctrlKey || event.metaKey || event.shiftKey,
|
||||
hasRaycastHit: Boolean(picked || rawPickedNodeName),
|
||||
});
|
||||
},
|
||||
@ -2153,7 +2540,7 @@ function ModelViewerComponent({
|
||||
emitContextMenuSelection({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
appendToSelection: event.ctrlKey || event.metaKey,
|
||||
appendToSelection: event.ctrlKey || event.metaKey || event.shiftKey,
|
||||
});
|
||||
};
|
||||
|
||||
@ -2297,6 +2684,10 @@ export const ModelViewer = memo(
|
||||
prevProps.defaultExploded === nextProps.defaultExploded &&
|
||||
prevProps.explodedProgress === nextProps.explodedProgress &&
|
||||
prevProps.selectedNodeName === nextProps.selectedNodeName &&
|
||||
areStringArraysEqual(
|
||||
prevProps.selectedNodeNames,
|
||||
nextProps.selectedNodeNames,
|
||||
) &&
|
||||
prevProps.showPanelLeaders === nextProps.showPanelLeaders &&
|
||||
prevProps.showHoleHighlights === nextProps.showHoleHighlights &&
|
||||
prevProps.onHoleHover === nextProps.onHoleHover &&
|
||||
@ -2306,6 +2697,7 @@ export const ModelViewer = memo(
|
||||
prevProps.clearAlpha === nextProps.clearAlpha &&
|
||||
prevProps.enableTranslateGizmo === nextProps.enableTranslateGizmo &&
|
||||
prevProps.gizmoMode === nextProps.gizmoMode &&
|
||||
prevProps.gizmoSize === nextProps.gizmoSize &&
|
||||
areStringRecordsEqual(
|
||||
prevProps.gizmoAxisColors,
|
||||
nextProps.gizmoAxisColors,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import * as THREE from "three";
|
||||
import { ModelViewer } from "@/components/3d/ModelViewer";
|
||||
|
||||
@ -169,7 +169,28 @@ describe("ModelViewer gizmo mode switching", () => {
|
||||
resolveDefaultTransformTargetMock.mockClear();
|
||||
sceneAddMock.mockClear();
|
||||
sceneRemoveMock.mockClear();
|
||||
loadModelMock.mockResolvedValue(undefined);
|
||||
getNamedNodeNamesMock.mockReturnValue(["panel-a"]);
|
||||
getModelRootMock.mockReturnValue(null);
|
||||
getNodeWorldOffsetMock.mockImplementation(
|
||||
() => [0, 0, 0] as [number, number, number],
|
||||
);
|
||||
getNodeLocalRotationMock.mockImplementation(
|
||||
() => [0, 0, 0, 1] as [number, number, number, number],
|
||||
);
|
||||
pickPartNameFromScreenMock.mockReturnValue(null);
|
||||
resolveObjectByNodeNameMock.mockImplementation((nodeName: string | null) =>
|
||||
nodeName ? targetObject : null,
|
||||
);
|
||||
resolveDefaultTransformTargetMock.mockReturnValue({
|
||||
nodeName: "panel-a",
|
||||
object: targetObject,
|
||||
});
|
||||
orbitControls.enabled = true;
|
||||
targetObject.clear();
|
||||
targetObject.add(
|
||||
new THREE.Mesh(new THREE.BoxGeometry(10, 10, 10), new THREE.MeshBasicMaterial()),
|
||||
);
|
||||
transformControlsInstances.splice(0, transformControlsInstances.length);
|
||||
targetObject.position.set(0, 0, 0);
|
||||
targetObject.quaternion.set(0, 0, 0, 1);
|
||||
@ -214,8 +235,10 @@ describe("ModelViewer gizmo mode switching", () => {
|
||||
await flushViewer();
|
||||
|
||||
expect(transformControlsInstances).toHaveLength(1);
|
||||
expect(transformControlsInstances[0]?.attach).toHaveBeenCalledWith(targetObject);
|
||||
expect(transformControlsInstances[0]?.object).toBe(targetObject);
|
||||
expect(transformControlsInstances[0]?.attach).toHaveBeenCalled();
|
||||
expect(transformControlsInstances[0]?.object?.name).toBe(
|
||||
"panel-a__model_viewer_gizmo_anchor__",
|
||||
);
|
||||
expect(transformControlsInstances[0]?.helper.visible).toBe(true);
|
||||
expect(transformControlsInstances[0]?.setMode).toHaveBeenLastCalledWith("translate");
|
||||
|
||||
@ -231,7 +254,9 @@ describe("ModelViewer gizmo mode switching", () => {
|
||||
await flushViewer();
|
||||
|
||||
expect(transformControlsInstances).toHaveLength(1);
|
||||
expect(transformControlsInstances[0]?.object).toBe(targetObject);
|
||||
expect(transformControlsInstances[0]?.object?.name).toBe(
|
||||
"panel-a__model_viewer_gizmo_anchor__",
|
||||
);
|
||||
expect(transformControlsInstances[0]?.helper.visible).toBe(true);
|
||||
expect(transformControlsInstances[0]?.setMode).toHaveBeenLastCalledWith("rotate");
|
||||
expect(transformControlsInstances[0]?.setRotationSnap).toHaveBeenLastCalledWith(
|
||||
@ -239,6 +264,35 @@ describe("ModelViewer gizmo mode switching", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows callers to enlarge the transform gizmo for compact viewports", async () => {
|
||||
const view = render(
|
||||
<ModelViewer
|
||||
modelUrl="/demo.glb"
|
||||
enableTranslateGizmo={true}
|
||||
selectedNodeName="panel-a"
|
||||
gizmoSize={0.98}
|
||||
/>,
|
||||
);
|
||||
|
||||
await flushViewer();
|
||||
|
||||
expect(transformControlsInstances[0]?.setSize).toHaveBeenLastCalledWith(0.98);
|
||||
|
||||
view.rerender(
|
||||
<ModelViewer
|
||||
modelUrl="/demo.glb"
|
||||
enableTranslateGizmo={true}
|
||||
selectedNodeName="panel-a"
|
||||
gizmoSize={1.1}
|
||||
/>,
|
||||
);
|
||||
|
||||
await flushViewer();
|
||||
|
||||
expect(transformControlsInstances).toHaveLength(1);
|
||||
expect(transformControlsInstances[0]?.setSize).toHaveBeenLastCalledWith(1.1);
|
||||
});
|
||||
|
||||
it("旋转模式默认 15 度吸附,按住 Ctrl 时切成自由旋转", async () => {
|
||||
render(
|
||||
<ModelViewer
|
||||
@ -303,33 +357,143 @@ describe("ModelViewer gizmo mode switching", () => {
|
||||
expect(transformControlsInstances[0]?.helper.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("移动时只显示有变化的轴,并显示 mm 数值", async () => {
|
||||
it("supports moving multiple selected nodes with one gizmo drag", async () => {
|
||||
const panelA = new THREE.Object3D();
|
||||
const panelB = new THREE.Object3D();
|
||||
[panelA, panelB].forEach((object) => {
|
||||
object.add(
|
||||
new THREE.Mesh(
|
||||
new THREE.BoxGeometry(10, 10, 10),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
),
|
||||
);
|
||||
object.updateMatrixWorld(true);
|
||||
});
|
||||
const objectByNodeName = new Map([
|
||||
["panel-a", panelA],
|
||||
["panel-b", panelB],
|
||||
]);
|
||||
resolveObjectByNodeNameMock.mockImplementation((nodeName: string | null) =>
|
||||
nodeName ? objectByNodeName.get(nodeName) ?? null : null,
|
||||
);
|
||||
getNodeWorldOffsetMock.mockImplementation((nodeName: string | null) => {
|
||||
const object = nodeName ? objectByNodeName.get(nodeName) : null;
|
||||
return object
|
||||
? ([object.position.x, object.position.y, object.position.z] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
])
|
||||
: null;
|
||||
});
|
||||
const handleNodePositionOffsetChange = jest.fn();
|
||||
|
||||
render(
|
||||
<ModelViewer
|
||||
modelUrl="/demo.glb"
|
||||
enableTranslateGizmo={true}
|
||||
selectedNodeName="panel-b"
|
||||
selectedNodeNames={["panel-a", "panel-b"]}
|
||||
gizmoMode="translate"
|
||||
onNodePositionOffsetChange={handleNodePositionOffsetChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await flushViewer();
|
||||
|
||||
expect(transformControlsInstances[0]?.attach).toHaveBeenCalled();
|
||||
expect(transformControlsInstances[0]?.object?.name).toBe(
|
||||
"panel-b__model_viewer_gizmo_anchor__",
|
||||
);
|
||||
|
||||
act(() => {
|
||||
transformControlsInstances[0]?.dispatchEvent({ type: "mouseDown" });
|
||||
transformControlsInstances[0]!.object!.position.set(20, 0, 0);
|
||||
transformControlsInstances[0]!.object!.updateMatrixWorld(true);
|
||||
transformControlsInstances[0]?.dispatchEvent({ type: "objectChange" });
|
||||
});
|
||||
|
||||
expect(handleNodePositionOffsetChange).toHaveBeenCalledWith({
|
||||
nodeName: "panel-a",
|
||||
offset: [20, 0, 0],
|
||||
});
|
||||
expect(handleNodePositionOffsetChange).toHaveBeenCalledWith({
|
||||
nodeName: "panel-b",
|
||||
offset: [20, 0, 0],
|
||||
});
|
||||
});
|
||||
|
||||
it("移动时只显示有变化的轴,并显示 mm 数值", async () => {
|
||||
const handleNodePositionOffsetChange = jest.fn();
|
||||
const view = render(
|
||||
<ModelViewer
|
||||
modelUrl="/demo.glb"
|
||||
enableTranslateGizmo={true}
|
||||
selectedNodeName="panel-a"
|
||||
gizmoMode="translate"
|
||||
onNodePositionOffsetChange={handleNodePositionOffsetChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await flushViewer();
|
||||
|
||||
getNodeWorldOffsetMock.mockReturnValue([0, 0, 0]);
|
||||
expect(transformControlsInstances).toHaveLength(1);
|
||||
expect(transformControlsInstances[0]?.object?.name).toBe(
|
||||
"panel-a__model_viewer_gizmo_anchor__",
|
||||
);
|
||||
act(() => {
|
||||
transformControlsInstances[0]?.dispatchEvent({ type: "mouseDown" });
|
||||
});
|
||||
|
||||
getNodeWorldOffsetMock.mockReturnValue([125, 0, 0]);
|
||||
act(() => {
|
||||
targetObject.position.set(125, 0, 0);
|
||||
targetObject.updateMatrixWorld(true);
|
||||
transformControlsInstances[0]!.object!.position.set(125, 0, 0);
|
||||
transformControlsInstances[0]!.object!.updateMatrixWorld(true);
|
||||
transformControlsInstances[0]?.dispatchEvent({ type: "objectChange" });
|
||||
});
|
||||
|
||||
expect(screen.getByText("X +125mm")).toBeInTheDocument();
|
||||
expect(targetObject.position.x).toBe(125);
|
||||
expect(handleNodePositionOffsetChange).toHaveBeenCalledWith({
|
||||
nodeName: "panel-a",
|
||||
offset: [125, 0, 0],
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("X +125mm")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText(/Y /)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Z /)).not.toBeInTheDocument();
|
||||
|
||||
setNodePositionOffsetsMock.mockClear();
|
||||
view.rerender(
|
||||
<ModelViewer
|
||||
modelUrl="/demo.glb"
|
||||
enableTranslateGizmo={true}
|
||||
selectedNodeName="panel-a"
|
||||
gizmoMode="translate"
|
||||
nodePositionOffsets={[{ nodeName: "panel-a", offset: [125, 0, 0] }]}
|
||||
onNodePositionOffsetChange={handleNodePositionOffsetChange}
|
||||
/>,
|
||||
);
|
||||
await flushViewer();
|
||||
expect(setNodePositionOffsetsMock).not.toHaveBeenCalled();
|
||||
|
||||
view.rerender(
|
||||
<ModelViewer
|
||||
modelUrl="/demo.glb"
|
||||
enableTranslateGizmo={true}
|
||||
selectedNodeName="panel-a"
|
||||
gizmoMode="translate"
|
||||
nodePositionOffsets={[{ nodeName: "panel-a", offset: [130, 0, 0] }]}
|
||||
onNodePositionOffsetChange={handleNodePositionOffsetChange}
|
||||
/>,
|
||||
);
|
||||
await flushViewer();
|
||||
expect(setNodePositionOffsetsMock).toHaveBeenCalledWith(
|
||||
[{ nodeName: "panel-a", offset: [130, 0, 0] }],
|
||||
[],
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("旋转时只显示有变化的轴,并显示角度数值", async () => {
|
||||
@ -357,13 +521,15 @@ describe("ModelViewer gizmo mode switching", () => {
|
||||
];
|
||||
getNodeLocalRotationMock.mockReturnValue(rotation15);
|
||||
act(() => {
|
||||
targetObject.quaternion.set(...rotation15);
|
||||
targetObject.updateMatrixWorld(true);
|
||||
transformControlsInstances[0]!.object!.quaternion.set(...rotation15);
|
||||
transformControlsInstances[0]!.object!.updateMatrixWorld(true);
|
||||
transformControlsInstances[0]!.axis = "Z";
|
||||
transformControlsInstances[0]?.dispatchEvent({ type: "objectChange" });
|
||||
});
|
||||
|
||||
expect(screen.getByText("Z +15°")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Z +15°")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText(/X /)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Y /)).not.toBeInTheDocument();
|
||||
});
|
||||
@ -386,6 +552,8 @@ describe("ModelViewer gizmo mode switching", () => {
|
||||
act(() => {
|
||||
targetObject.quaternion.copy(baselineRotation);
|
||||
targetObject.updateMatrixWorld(true);
|
||||
transformControlsInstances[0]!.object!.quaternion.copy(baselineRotation);
|
||||
transformControlsInstances[0]!.object!.updateMatrixWorld(true);
|
||||
transformControlsInstances[0]?.dispatchEvent({ type: "mouseDown" });
|
||||
});
|
||||
|
||||
@ -402,13 +570,15 @@ describe("ModelViewer gizmo mode switching", () => {
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
targetObject.quaternion.copy(nextRotation);
|
||||
targetObject.updateMatrixWorld(true);
|
||||
transformControlsInstances[0]!.object!.quaternion.copy(nextRotation);
|
||||
transformControlsInstances[0]!.object!.updateMatrixWorld(true);
|
||||
transformControlsInstances[0]!.axis = "Z";
|
||||
transformControlsInstances[0]?.dispatchEvent({ type: "objectChange" });
|
||||
});
|
||||
|
||||
expect(screen.getByText("Z +15°")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Z +15°")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText(/X /)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Y /)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -69,6 +69,14 @@ jest.mock('@/lib/3d/engine', () => ({
|
||||
nodes.map((node) => {
|
||||
const object = new THREE.Group()
|
||||
object.name = node.nodeName
|
||||
if (node.nodeName === 'panel-a') {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(10, 4, 2),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
mesh.position.set(5, 2, 1)
|
||||
object.add(mesh)
|
||||
}
|
||||
return [node.nodeName, object]
|
||||
}),
|
||||
)
|
||||
@ -97,6 +105,14 @@ jest.mock('@/lib/3d/engine', () => ({
|
||||
if (!instance.nodeObjects.has(node.nodeName)) {
|
||||
const object = new THREE.Group()
|
||||
object.name = node.nodeName
|
||||
if (node.nodeName === 'panel-a') {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(10, 4, 2),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
mesh.position.set(5, 2, 1)
|
||||
object.add(mesh)
|
||||
}
|
||||
instance.nodeObjects.set(node.nodeName, object)
|
||||
}
|
||||
})
|
||||
@ -235,7 +251,36 @@ describe('ModelViewer', () => {
|
||||
const attachedObject = latestTransformControls.attach.mock.calls.at(-1)?.[0] as
|
||||
| THREE.Object3D
|
||||
| undefined
|
||||
expect(attachedObject?.name).toBe('node-1')
|
||||
expect(attachedObject?.name).toBe('node-1__model_viewer_gizmo_anchor__')
|
||||
})
|
||||
})
|
||||
|
||||
it('操作轴会挂到选中节点的几何中心,而不是节点原点', async () => {
|
||||
render(
|
||||
<ModelViewer
|
||||
worldNodes={[
|
||||
{
|
||||
nodeName: 'panel-a',
|
||||
modelUrl: '/panel.glb',
|
||||
source: 'panel',
|
||||
},
|
||||
]}
|
||||
worldFrameMode="initial"
|
||||
enableTranslateGizmo
|
||||
selectedNodeName="panel-a"
|
||||
showSceneGrid
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTransformControlsInstances[0]?.attach).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const attachedObject = mockTransformControlsInstances[0]?.attach.mock.calls.at(-1)?.[0] as
|
||||
| THREE.Object3D
|
||||
| undefined
|
||||
expect(attachedObject?.position.toArray()).toEqual([5, 2, 1])
|
||||
})
|
||||
})
|
||||
|
||||
@ -448,7 +493,7 @@ describe('ModelViewer', () => {
|
||||
expect(onWorldNodeTransformChange).toHaveBeenCalledWith({
|
||||
nodeName: 'node-1',
|
||||
position: [1, 2, 3],
|
||||
rotation: [0, 0, 0],
|
||||
rotation: [expect.closeTo(0), expect.closeTo(0), expect.closeTo(0)],
|
||||
})
|
||||
expect(object.position.toArray()).toEqual([10, 20, 30])
|
||||
})
|
||||
|
||||
@ -45,9 +45,11 @@ interface ModelPreviewPanelProps {
|
||||
onToggleSceneGrid?: () => void
|
||||
enableTranslateGizmo?: boolean
|
||||
gizmoMode?: 'translate' | 'rotate'
|
||||
gizmoSize?: number
|
||||
gizmoAxisColors?: Partial<Record<'X' | 'Y' | 'Z', string>>
|
||||
onGizmoModeChange?: (mode: 'translate' | 'rotate') => void
|
||||
selectedNodeName?: string | null
|
||||
selectedNodeNames?: string[]
|
||||
selectableNodeNames?: string[]
|
||||
nodePositionOffsets?: NodePositionOffset[]
|
||||
nodeRotationStates?: NodeRotationState[]
|
||||
@ -136,9 +138,11 @@ export function ModelPreviewPanel({
|
||||
onToggleSceneGrid: externalOnToggleSceneGrid,
|
||||
enableTranslateGizmo = false,
|
||||
gizmoMode = 'translate',
|
||||
gizmoSize,
|
||||
gizmoAxisColors,
|
||||
onGizmoModeChange,
|
||||
selectedNodeName = null,
|
||||
selectedNodeNames = [],
|
||||
selectableNodeNames = [],
|
||||
nodePositionOffsets = [],
|
||||
nodeRotationStates = [],
|
||||
@ -335,6 +339,7 @@ export function ModelPreviewPanel({
|
||||
worldNodes={worldNodes}
|
||||
worldFrameMode={worldFrameMode}
|
||||
selectedNodeName={selectedNodeName}
|
||||
selectedNodeNames={selectedNodeNames}
|
||||
selectableNodeNames={selectableNodeNames}
|
||||
nodePositionOffsets={nodePositionOffsets}
|
||||
nodeRotationStates={nodeRotationStates}
|
||||
@ -361,6 +366,7 @@ export function ModelPreviewPanel({
|
||||
showSceneGrid={effectiveShowSceneGrid}
|
||||
enableTranslateGizmo={enableTranslateGizmo}
|
||||
gizmoMode={gizmoMode}
|
||||
gizmoSize={gizmoSize}
|
||||
gizmoAxisColors={gizmoAxisColors}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@ -69,7 +69,7 @@ export function ViewportStageShell({
|
||||
<div className="absolute inset-0 z-0">{children}</div>
|
||||
|
||||
{/* White Obsidian Atelier vertical viewport tools */}
|
||||
<div className="absolute left-8 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1 rounded-[8px] bg-white/72 p-1.5 shadow-[0_18px_48px_rgba(45,51,53,0.14)] ring-1 ring-white/70 backdrop-blur-2xl transition-all duration-300 supports-[backdrop-filter]:bg-white/62">
|
||||
<div className="absolute left-2 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1 rounded-[8px] bg-white/72 p-1.5 shadow-[0_18px_48px_rgba(45,51,53,0.14)] ring-1 ring-white/70 backdrop-blur-2xl transition-all duration-300 supports-[backdrop-filter]:bg-white/62">
|
||||
{/* 交互模式 */}
|
||||
{enableTranslateGizmo && (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
@ -136,7 +136,7 @@ export function ViewportStageShell({
|
||||
</div>
|
||||
|
||||
{/* 其他覆盖层 */}
|
||||
{overlayTopRight && <div className="absolute top-6 right-6 z-20">{overlayTopRight}</div>}
|
||||
{overlayTopRight && <div className="absolute top-2 right-2 z-20">{overlayTopRight}</div>}
|
||||
{overlayLeft && (
|
||||
<div className={cn('absolute z-20 h-full', overlayLeftClassName || 'left-24 top-1/2 -translate-y-1/2')}>
|
||||
{overlayLeft}
|
||||
|
||||
@ -221,7 +221,7 @@ export async function createNonIntegratedReviewSheetWorkbook(
|
||||
|
||||
const worksheet = workbook.addWorksheet('评审资料')
|
||||
worksheet.properties.defaultRowHeight = 24
|
||||
worksheet.views = [{ state: 'frozen', ySplit: 10 }]
|
||||
worksheet.views = [{ state: 'frozen', ySplit: 12 }]
|
||||
worksheet.columns = [
|
||||
{ width: 8 },
|
||||
{ width: 30 },
|
||||
@ -268,20 +268,35 @@ export async function createNonIntegratedReviewSheetWorkbook(
|
||||
worksheet.getCell('B8').value = sheet.color.trim()
|
||||
;['A8', 'B8'].forEach((address, index) => applyTableCellStyle(worksheet.getCell(address), index === 0))
|
||||
|
||||
worksheet.mergeCells('C5:E8')
|
||||
worksheet.getCell('A9').value = '条码'
|
||||
worksheet.getCell('B9').value = sheet.barcode?.trim() ?? ''
|
||||
;['A9', 'B9'].forEach((address, index) => applyTableCellStyle(worksheet.getCell(address), index === 0))
|
||||
|
||||
worksheet.getCell('A10').value = '审样结果'
|
||||
worksheet.getCell('B10').value = sheet.reviewResult?.trim() ?? ''
|
||||
;['A10', 'B10'].forEach((address, index) => applyTableCellStyle(worksheet.getCell(address), index === 0))
|
||||
|
||||
worksheet.mergeCells('C5:E10')
|
||||
const productImageCell = worksheet.getCell('C5')
|
||||
productImageCell.value = sheet.productImage.trim() ? undefined : '产品图片'
|
||||
productImageCell.alignment = { vertical: 'middle', horizontal: 'center' }
|
||||
productImageCell.font = { name: 'Microsoft YaHei', size: 10, color: { argb: 'FF8A95A2' } }
|
||||
;['C5', 'D5', 'E5', 'C6', 'D6', 'E6', 'C7', 'D7', 'E7', 'C8', 'D8', 'E8'].forEach((address) =>
|
||||
;[
|
||||
'C5', 'D5', 'E5',
|
||||
'C6', 'D6', 'E6',
|
||||
'C7', 'D7', 'E7',
|
||||
'C8', 'D8', 'E8',
|
||||
'C9', 'D9', 'E9',
|
||||
'C10', 'D10', 'E10',
|
||||
].forEach((address) =>
|
||||
applyOuterBorder(worksheet.getCell(address)),
|
||||
)
|
||||
;[5, 6, 7, 8].forEach((rowNumber) => {
|
||||
;[5, 6, 7, 8, 9, 10].forEach((rowNumber) => {
|
||||
worksheet.getRow(rowNumber).height = 34
|
||||
})
|
||||
|
||||
worksheet.getRow(9).height = 24
|
||||
;['A10', 'B10', 'C10', 'D10', 'E10'].forEach((address, index) => {
|
||||
worksheet.getRow(11).height = 24
|
||||
;['A12', 'B12', 'C12', 'D12', 'E12'].forEach((address, index) => {
|
||||
const headerLabels = ['序号', '问题描述', '修改方式', '问题图片', '参考图片']
|
||||
worksheet.getCell(address).value = headerLabels[index]
|
||||
applyTableCellStyle(worksheet.getCell(address), true)
|
||||
@ -291,7 +306,7 @@ export async function createNonIntegratedReviewSheetWorkbook(
|
||||
|
||||
const issues = sheet.issues
|
||||
const imageTasks: Promise<void>[] = []
|
||||
let rowNumber = 11
|
||||
let rowNumber = 13
|
||||
|
||||
issues.forEach((issue, index) => {
|
||||
const issueImages = getReviewIssueImages(issue, 'issue')
|
||||
|
||||
@ -199,6 +199,66 @@ describe('intakeNonIntegratedProductService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('applies brand prefix to a new product spu saved from opening code', async () => {
|
||||
const gujiaBrand = '\u4ee3\u7406\u54c1\u724c-\u987e\u5bb6'
|
||||
const payload = createPayload({
|
||||
id: 'non-integrated-brand-code-create',
|
||||
spu: 'SZ0242',
|
||||
brand: gujiaBrand,
|
||||
adaptableBrands: [gujiaBrand],
|
||||
productCode: 'SZ0242',
|
||||
openingReference: {
|
||||
productName: 'Brand code product',
|
||||
temporaryCode: 'SZ0242',
|
||||
brand: gujiaBrand,
|
||||
},
|
||||
})
|
||||
|
||||
mockedRepository.findBySpu.mockResolvedValue(null as never)
|
||||
mockedRepository.findDeletedBySpu.mockResolvedValue(null as never)
|
||||
mockedRepository.findById.mockResolvedValue(null as never)
|
||||
mockedRepository.create.mockResolvedValue(
|
||||
createRecord({
|
||||
id: 'non-integrated-brand-code-create',
|
||||
spu: 'GBGJ-SZ0242',
|
||||
spuBase: 'SZ0242',
|
||||
brand: gujiaBrand,
|
||||
payload,
|
||||
}) as never,
|
||||
)
|
||||
mockedRepository.findActiveById.mockResolvedValue(
|
||||
createRecord({
|
||||
id: 'non-integrated-brand-code-create',
|
||||
spu: 'GBGJ-SZ0242',
|
||||
spuBase: 'SZ0242',
|
||||
brand: gujiaBrand,
|
||||
payload,
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const savedProduct = await intakeNonIntegratedProductService.save(payload, baseUser)
|
||||
|
||||
expect(mockedRepository.findBySpu).toHaveBeenCalledWith(
|
||||
'GBGJ-SZ0242',
|
||||
'non-integrated-brand-code-create',
|
||||
gujiaBrand,
|
||||
)
|
||||
expect(mockedRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'non-integrated-brand-code-create',
|
||||
spu: 'GBGJ-SZ0242',
|
||||
spuBase: 'SZ0242',
|
||||
brand: gujiaBrand,
|
||||
payload: expect.objectContaining({
|
||||
spu: 'GBGJ-SZ0242',
|
||||
productCode: 'GBGJ-SZ0242',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(savedProduct.spu).toBe('GBGJ-SZ0242')
|
||||
expect(savedProduct.productCode).toBe('GBGJ-SZ0242')
|
||||
})
|
||||
|
||||
it('allows creating a copied product when the target brand prefix changes the full SPU', async () => {
|
||||
const payload = createPayload({
|
||||
id: 'non-integrated-prefixed-create',
|
||||
|
||||
@ -0,0 +1,900 @@
|
||||
import { existsSync } from "fs";
|
||||
import { readFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import JSZip from "jszip";
|
||||
import sharp from "sharp";
|
||||
import { resolveNonIntegratedAssetFilePath } from "@/app/intake/non-integrated/nonIntegratedAssetServer";
|
||||
import type {
|
||||
NonIntegratedFactoryRow,
|
||||
NonIntegratedProductRecord,
|
||||
NonIntegratedSkuItem,
|
||||
} from "@/app/intake/non-integrated/nonIntegratedStorage";
|
||||
import { AppError, ErrorCode } from "@/server/core/errors";
|
||||
|
||||
export type IntakeProductFactoryContractType =
|
||||
| "prepaid"
|
||||
| "monthly"
|
||||
| "standard-jianhua"
|
||||
| "standard-jianxin"
|
||||
| "standard-jianyue";
|
||||
|
||||
export type IntakeProductFactoryContractBatch = number | "all";
|
||||
|
||||
type ContractTemplateConfig = {
|
||||
fileName: string;
|
||||
outputLabel: string;
|
||||
kind: "order" | "standard";
|
||||
};
|
||||
|
||||
type ContractFactoryRow = {
|
||||
skuId: string;
|
||||
batchNo: number;
|
||||
image: string;
|
||||
model: string;
|
||||
skuCode: string;
|
||||
barcode: string;
|
||||
specName: string;
|
||||
material: string;
|
||||
supplierName: string;
|
||||
supplierCode: string;
|
||||
minimumOrderQuantity: string;
|
||||
taxIncludedUnitPrice: string;
|
||||
leadTime: string;
|
||||
};
|
||||
|
||||
type XmlSegment = {
|
||||
xml: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
type PreparedImage = {
|
||||
relId: string;
|
||||
fileName: string;
|
||||
cx: number;
|
||||
cy: number;
|
||||
};
|
||||
|
||||
const CONTRACT_TEMPLATE_DIRECTORY = path.join(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"server",
|
||||
"templates",
|
||||
"intake-contracts",
|
||||
);
|
||||
|
||||
const CONTRACT_TEMPLATE_CONFIG: Record<
|
||||
IntakeProductFactoryContractType,
|
||||
ContractTemplateConfig
|
||||
> = {
|
||||
prepaid: {
|
||||
fileName: "YF-订单合同-V2.0-3.2docx.docx",
|
||||
outputLabel: "预付合同",
|
||||
kind: "order",
|
||||
},
|
||||
monthly: {
|
||||
fileName: "YJ订单合同-V2.0-3.15.docx",
|
||||
outputLabel: "月结合同",
|
||||
kind: "order",
|
||||
},
|
||||
"standard-jianhua": {
|
||||
fileName: "SPU_采购订单标准协议_供应商名称_简华模板.docx",
|
||||
outputLabel: "采购标准合同-简华",
|
||||
kind: "standard",
|
||||
},
|
||||
"standard-jianxin": {
|
||||
fileName: "SPU_采购订单标准协议_供应商名称_简心模板.docx",
|
||||
outputLabel: "采购标准合同-简心",
|
||||
kind: "standard",
|
||||
},
|
||||
"standard-jianyue": {
|
||||
fileName: "SPU_采购订单标准协议_供应商名称_简悦模板.docx",
|
||||
outputLabel: "采购标准合同-简悦",
|
||||
kind: "standard",
|
||||
},
|
||||
};
|
||||
|
||||
const STANDARD_CONTRACT_HEADERS = [
|
||||
"型号",
|
||||
"条形码",
|
||||
"规格名称",
|
||||
"数量",
|
||||
"交期",
|
||||
];
|
||||
|
||||
const ORDER_CONTRACT_HEADERS = [
|
||||
"序号",
|
||||
"图片",
|
||||
"条码",
|
||||
"规格名",
|
||||
"材质",
|
||||
"下单量",
|
||||
"含税单价(元)",
|
||||
"合计(元)",
|
||||
"交货时间",
|
||||
];
|
||||
|
||||
const IMAGE_MAX_EMU = 620_000;
|
||||
const PIXEL_TO_EMU = 9525;
|
||||
const CONTRACT_FIELD_PLACEHOLDER = "____________________";
|
||||
|
||||
export function normalizeProductFactoryContractType(
|
||||
value: string | null,
|
||||
): IntakeProductFactoryContractType | null {
|
||||
if (!value) return null;
|
||||
return Object.prototype.hasOwnProperty.call(CONTRACT_TEMPLATE_CONFIG, value)
|
||||
? (value as IntakeProductFactoryContractType)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function normalizeProductFactoryContractBatch(
|
||||
value: string | null,
|
||||
): IntakeProductFactoryContractBatch {
|
||||
if (!value || value === "all") return "all";
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : "all";
|
||||
}
|
||||
|
||||
export async function createIntakeProductFactoryContractDocx({
|
||||
product,
|
||||
contractType,
|
||||
batch,
|
||||
}: {
|
||||
product: NonIntegratedProductRecord;
|
||||
contractType: IntakeProductFactoryContractType;
|
||||
batch: IntakeProductFactoryContractBatch;
|
||||
}) {
|
||||
const config = CONTRACT_TEMPLATE_CONFIG[contractType];
|
||||
const rows = resolveContractRows(product, batch);
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_REQUIRED_FIELD,
|
||||
"当前批次没有可下载的产品定厂 SKU",
|
||||
);
|
||||
}
|
||||
|
||||
const templatePath = path.join(CONTRACT_TEMPLATE_DIRECTORY, config.fileName);
|
||||
if (!existsSync(templatePath)) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_NOT_FOUND,
|
||||
"合同模板不存在,请先检查服务端模板文件",
|
||||
);
|
||||
}
|
||||
|
||||
const templateBuffer = await readFile(templatePath);
|
||||
const zip = await JSZip.loadAsync(templateBuffer);
|
||||
const documentFile = zip.file("word/document.xml");
|
||||
const relationshipFile = zip.file("word/_rels/document.xml.rels");
|
||||
const contentTypesFile = zip.file("[Content_Types].xml");
|
||||
|
||||
if (!documentFile || !relationshipFile || !contentTypesFile) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_UPLOAD_FAILED,
|
||||
"合同模板结构不完整,无法生成 Word",
|
||||
);
|
||||
}
|
||||
|
||||
let documentXml = await documentFile.async("string");
|
||||
let relationshipsXml = await relationshipFile.async("string");
|
||||
let contentTypesXml = await contentTypesFile.async("string");
|
||||
const supplierName = resolveSupplierName(rows);
|
||||
const imageState = createImageState(relationshipsXml, documentXml);
|
||||
|
||||
const commentCleanResult = removeTemplateComments(
|
||||
zip,
|
||||
documentXml,
|
||||
relationshipsXml,
|
||||
contentTypesXml,
|
||||
);
|
||||
documentXml = commentCleanResult.documentXml;
|
||||
relationshipsXml = commentCleanResult.relationshipsXml;
|
||||
contentTypesXml = commentCleanResult.contentTypesXml;
|
||||
|
||||
documentXml = clearContractOpenFields(documentXml);
|
||||
|
||||
if (config.kind === "standard") {
|
||||
documentXml = replaceStandardContractTable(documentXml, rows);
|
||||
} else {
|
||||
const nextOrderTable = await replaceOrderContractTable(
|
||||
zip,
|
||||
documentXml,
|
||||
relationshipsXml,
|
||||
rows,
|
||||
imageState,
|
||||
);
|
||||
documentXml = nextOrderTable.documentXml;
|
||||
relationshipsXml = nextOrderTable.relationshipsXml;
|
||||
contentTypesXml = ensurePngContentType(contentTypesXml);
|
||||
}
|
||||
|
||||
documentXml = ensureDrawingNamespaces(documentXml);
|
||||
zip.file("word/document.xml", documentXml);
|
||||
zip.file("word/_rels/document.xml.rels", relationshipsXml);
|
||||
zip.file("[Content_Types].xml", contentTypesXml);
|
||||
|
||||
const outputName = buildOutputFileName(product, config.outputLabel, supplierName);
|
||||
const bytes = await zip.generateAsync({
|
||||
type: "uint8array",
|
||||
compression: "DEFLATE",
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: outputName,
|
||||
bytes,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContractRows(
|
||||
product: NonIntegratedProductRecord,
|
||||
batch: IntakeProductFactoryContractBatch,
|
||||
): ContractFactoryRow[] {
|
||||
const factoryRowBySkuId = new Map(
|
||||
product.factoryRows.map((row) => [row.skuId, row]),
|
||||
);
|
||||
const skuById = new Map(product.skuItems.map((sku) => [sku.id, sku]));
|
||||
const rows = product.skuItems.map((sku) =>
|
||||
mergeSkuAndFactoryRow(sku, factoryRowBySkuId.get(sku.id)),
|
||||
);
|
||||
|
||||
product.factoryRows.forEach((row) => {
|
||||
if (!skuById.has(row.skuId)) {
|
||||
rows.push(mergeSkuAndFactoryRow(null, row));
|
||||
}
|
||||
});
|
||||
|
||||
return rows
|
||||
.filter((row) => batch === "all" || row.batchNo === batch)
|
||||
.filter((row) =>
|
||||
[
|
||||
row.model,
|
||||
row.skuCode,
|
||||
row.barcode,
|
||||
row.specName,
|
||||
row.supplierName,
|
||||
].some((value) => value.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
function mergeSkuAndFactoryRow(
|
||||
sku: NonIntegratedSkuItem | null,
|
||||
row?: NonIntegratedFactoryRow,
|
||||
): ContractFactoryRow {
|
||||
return {
|
||||
skuId: row?.skuId ?? sku?.id ?? "",
|
||||
batchNo: sku?.batchNo || 1,
|
||||
image: sku?.image?.trim() ?? "",
|
||||
model: row?.model?.trim() || sku?.model?.trim() || "",
|
||||
skuCode: row?.skuCode?.trim() || sku?.skuCode?.trim() || "",
|
||||
barcode: row?.barcode?.trim() || sku?.barcode?.trim() || "",
|
||||
specName: row?.specName?.trim() || sku?.specName?.trim() || "",
|
||||
material: row?.material?.trim() || sku?.material?.trim() || "",
|
||||
supplierName: row?.supplierName?.trim() || sku?.manufacturer?.trim() || "",
|
||||
supplierCode:
|
||||
row?.supplierCode?.trim().toUpperCase() ||
|
||||
sku?.manufacturerCode?.trim().toUpperCase() ||
|
||||
"",
|
||||
minimumOrderQuantity: row?.minimumOrderQuantity?.trim() ?? "",
|
||||
taxIncludedUnitPrice: row?.taxIncludedUnitPrice?.trim() ?? "",
|
||||
leadTime: row?.leadTime?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSupplierName(rows: ContractFactoryRow[]) {
|
||||
return rows.find((row) => row.supplierName.trim())?.supplierName.trim() ?? "";
|
||||
}
|
||||
|
||||
function buildOutputFileName(
|
||||
product: NonIntegratedProductRecord,
|
||||
label: string,
|
||||
supplierName: string,
|
||||
) {
|
||||
const productLabel =
|
||||
product.spu?.trim() ||
|
||||
product.openingReference?.productName?.trim() ||
|
||||
product.productCode?.trim() ||
|
||||
"产品定厂";
|
||||
return `${sanitizeFileNameSegment(label)}_${sanitizeFileNameSegment(
|
||||
productLabel,
|
||||
)}_${sanitizeFileNameSegment(supplierName || "供应商")}.docx`;
|
||||
}
|
||||
|
||||
function sanitizeFileNameSegment(value: string) {
|
||||
return (value.trim() || "未命名").replace(/[\\/:*?"<>|]+/g, "_");
|
||||
}
|
||||
|
||||
function clearContractOpenFields(documentXml: string) {
|
||||
return collectSegments(documentXml, /<w:p[\s\S]*?<\/w:p>/g)
|
||||
.reverse()
|
||||
.reduce((xml, paragraph) => {
|
||||
const nextParagraphXml = clearContractOpenFieldsInParagraph(
|
||||
paragraph.xml,
|
||||
);
|
||||
return nextParagraphXml === paragraph.xml
|
||||
? xml
|
||||
: replaceSegment(xml, paragraph.start, paragraph.end, nextParagraphXml);
|
||||
}, documentXml);
|
||||
}
|
||||
|
||||
function removeTemplateComments(
|
||||
zip: JSZip,
|
||||
documentXml: string,
|
||||
relationshipsXml: string,
|
||||
contentTypesXml: string,
|
||||
) {
|
||||
for (const fileName of [
|
||||
"word/comments.xml",
|
||||
"word/commentsExtended.xml",
|
||||
"word/commentsIds.xml",
|
||||
"word/threadedComments.xml",
|
||||
"word/people.xml",
|
||||
]) {
|
||||
zip.remove(fileName);
|
||||
}
|
||||
|
||||
return {
|
||||
documentXml: documentXml
|
||||
.replace(/<w:commentRange(?:Start|End)\b[^>]*\/>/g, "")
|
||||
.replace(/<w:commentReference\b[^>]*\/>/g, ""),
|
||||
relationshipsXml: relationshipsXml.replace(
|
||||
/<Relationship\b(?=[^>]*(?:comments(?:Extended|Ids)?\.xml|threadedComments\.xml|people\.xml))[^>]*\/>/g,
|
||||
"",
|
||||
),
|
||||
contentTypesXml: contentTypesXml.replace(
|
||||
/<Override\b(?=[^>]*PartName="\/word\/(?:comments(?:Extended|Ids)?|threadedComments|people)\.xml")[^>]*\/>/g,
|
||||
"",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function clearContractOpenFieldsInParagraph(paragraphXml: string) {
|
||||
const paragraphText = getText(paragraphXml);
|
||||
|
||||
if (hasOrderContractNumberLabel(paragraphText)) {
|
||||
return replaceParagraphVisibleText(
|
||||
paragraphXml,
|
||||
paragraphText.replace(
|
||||
/(\u5408\u540c\u7f16\u53f7\s*[:\uff1a])[\s\S]*$/,
|
||||
`$1${CONTRACT_FIELD_PLACEHOLDER}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasStandardSupplierPartyLabel(paragraphText)) {
|
||||
return replaceParagraphVisibleText(
|
||||
paragraphXml,
|
||||
paragraphText.replace(
|
||||
/^([\s\S]*?\u4f9b\u5e94\u5546(?:\(\u4e59\u65b9\)|\uff08\u4e59\u65b9\uff09)[:\uff1a])[\s\S]*$/,
|
||||
`$1${CONTRACT_FIELD_PLACEHOLDER}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
paragraphText.includes("\u4f9b\u5e94\u5546\uff1a") &&
|
||||
paragraphText.includes("\u4e59\u65b9")
|
||||
) {
|
||||
return replaceParagraphVisibleText(
|
||||
paragraphXml,
|
||||
paragraphText.replace(
|
||||
/(\u4f9b\u5e94\u5546\uff1a)[\s\S]*?(\uff08\u4ee5\u4e0b\u7b80\u79f0\u4e59\u65b9\uff09)/,
|
||||
`$1${CONTRACT_FIELD_PLACEHOLDER}$2`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const label of [
|
||||
"\u8d26\u6237\u540d\u79f0\uff1a",
|
||||
"\u5f00\u6237\u884c\uff1a",
|
||||
"\u94f6\u884c\u8d26\u53f7\uff1a",
|
||||
]) {
|
||||
if (paragraphText.includes(label)) {
|
||||
const labelIndex = paragraphText.indexOf(label);
|
||||
const keepEnd = labelIndex + label.length;
|
||||
return replaceParagraphVisibleText(
|
||||
paragraphXml,
|
||||
`${paragraphText.slice(
|
||||
0,
|
||||
keepEnd,
|
||||
)}${CONTRACT_FIELD_PLACEHOLDER}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return paragraphXml;
|
||||
}
|
||||
|
||||
function hasOrderContractNumberLabel(text: string) {
|
||||
const normalized = text.replace(/\s+/g, "");
|
||||
return (
|
||||
normalized.includes("\u5408\u540c\u7f16\u53f7:") ||
|
||||
normalized.includes("\u5408\u540c\u7f16\u53f7\uff1a")
|
||||
);
|
||||
}
|
||||
|
||||
function hasStandardSupplierPartyLabel(text: string) {
|
||||
const normalized = text.replace(/\s+/g, "");
|
||||
return (
|
||||
normalized.includes("\u4f9b\u5e94\u5546(\u4e59\u65b9):") ||
|
||||
normalized.includes("\u4f9b\u5e94\u5546(\u4e59\u65b9)\uff1a") ||
|
||||
normalized.includes("\u4f9b\u5e94\u5546\uff08\u4e59\u65b9\uff09:") ||
|
||||
normalized.includes("\u4f9b\u5e94\u5546\uff08\u4e59\u65b9\uff09\uff1a")
|
||||
);
|
||||
}
|
||||
|
||||
function replaceParagraphTextNodes(
|
||||
paragraphXml: string,
|
||||
transform: (text: string, texts: string[], index: number) => string,
|
||||
) {
|
||||
const textSegments = collectSegments(
|
||||
paragraphXml,
|
||||
/<w:t(?:\s[^>]*)?>[\s\S]*?<\/w:t>/g,
|
||||
);
|
||||
const texts = textSegments.map((segment) => getText(segment.xml));
|
||||
const nextTexts = texts.map((text, index) => transform(text, texts, index));
|
||||
|
||||
return textSegments
|
||||
.reverse()
|
||||
.reduce((xml, segment, reverseIndex) => {
|
||||
const index = textSegments.length - reverseIndex - 1;
|
||||
const nextText = nextTexts[index] ?? "";
|
||||
const nextSegmentXml = segment.xml.replace(
|
||||
/(<w:t(?:\s[^>]*)?>)([\s\S]*?)(<\/w:t>)/,
|
||||
(_match, start: string, _value: string, end: string) =>
|
||||
`${start}${escapeXml(nextText)}${end}`,
|
||||
);
|
||||
return replaceSegment(xml, segment.start, segment.end, nextSegmentXml);
|
||||
}, paragraphXml);
|
||||
}
|
||||
|
||||
function replaceParagraphVisibleText(paragraphXml: string, nextText: string) {
|
||||
return replaceParagraphTextNodes(paragraphXml, (_text, _texts, index) =>
|
||||
index === 0 ? nextText : "",
|
||||
);
|
||||
}
|
||||
|
||||
function replaceStandardContractTable(
|
||||
documentXml: string,
|
||||
rows: ContractFactoryRow[],
|
||||
) {
|
||||
const table = findTableByHeaders(documentXml, STANDARD_CONTRACT_HEADERS);
|
||||
if (!table) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_UPLOAD_FAILED,
|
||||
"采购标准合同模板中没有找到明细表",
|
||||
);
|
||||
}
|
||||
|
||||
const bodyStart = table.headerIndex + 1;
|
||||
const bodyTemplate = table.rows[bodyStart]?.xml;
|
||||
if (!bodyTemplate) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_UPLOAD_FAILED,
|
||||
"采购标准合同模板明细行缺失",
|
||||
);
|
||||
}
|
||||
|
||||
const bodyRows = rows.map((row) =>
|
||||
buildTextRowFromTemplate(bodyTemplate, [
|
||||
buildStandardModel(row),
|
||||
row.barcode,
|
||||
row.specName,
|
||||
row.minimumOrderQuantity,
|
||||
row.leadTime,
|
||||
]),
|
||||
);
|
||||
const nextTable = replaceRowsInTable(
|
||||
table.tableXml,
|
||||
table.rows,
|
||||
bodyStart,
|
||||
table.rows.length,
|
||||
bodyRows.join(""),
|
||||
);
|
||||
|
||||
return replaceSegment(documentXml, table.start, table.end, nextTable);
|
||||
}
|
||||
|
||||
async function replaceOrderContractTable(
|
||||
zip: JSZip,
|
||||
documentXml: string,
|
||||
relationshipsXml: string,
|
||||
rows: ContractFactoryRow[],
|
||||
imageState: ReturnType<typeof createImageState>,
|
||||
) {
|
||||
const table = findTableByHeaders(documentXml, ORDER_CONTRACT_HEADERS);
|
||||
if (!table) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_UPLOAD_FAILED,
|
||||
"订单合同模板中没有找到采购清单",
|
||||
);
|
||||
}
|
||||
|
||||
const bodyStart = table.headerIndex + 1;
|
||||
const totalRowIndex = resolveOrderTotalRowIndex(table.rows);
|
||||
const bodyEnd = totalRowIndex ?? table.rows.length;
|
||||
const bodyTemplate = table.rows[bodyStart]?.xml;
|
||||
if (!bodyTemplate) {
|
||||
throw new AppError(
|
||||
ErrorCode.FILE_UPLOAD_FAILED,
|
||||
"订单合同模板采购清单明细行缺失",
|
||||
);
|
||||
}
|
||||
|
||||
const generatedRows: string[] = [];
|
||||
let nextRelationshipsXml = relationshipsXml;
|
||||
let quantityTotal = 0;
|
||||
let amountTotal = 0;
|
||||
let hasAmountTotal = false;
|
||||
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const quantity = parseNumber(row.minimumOrderQuantity);
|
||||
const unitPrice = parseNumber(row.taxIncludedUnitPrice);
|
||||
const lineTotal =
|
||||
quantity !== null && unitPrice !== null ? quantity * unitPrice : null;
|
||||
if (quantity !== null) quantityTotal += quantity;
|
||||
if (lineTotal !== null) {
|
||||
amountTotal += lineTotal;
|
||||
hasAmountTotal = true;
|
||||
}
|
||||
|
||||
const image = await prepareImage(zip, row.image, imageState);
|
||||
nextRelationshipsXml = imageState.relationshipsXml;
|
||||
generatedRows.push(
|
||||
buildOrderRowFromTemplate(bodyTemplate, [
|
||||
{ type: "text", value: String(index + 1) },
|
||||
{ type: "image", image, alt: row.model || `SKU ${index + 1}` },
|
||||
{ type: "text", value: row.barcode },
|
||||
{ type: "text", value: row.specName },
|
||||
{ type: "text", value: row.material },
|
||||
{ type: "text", value: row.minimumOrderQuantity },
|
||||
{ type: "text", value: row.taxIncludedUnitPrice },
|
||||
{ type: "text", value: formatNumber(lineTotal) },
|
||||
{ type: "text", value: row.leadTime },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const totalTemplate =
|
||||
totalRowIndex === undefined ? null : table.rows[totalRowIndex]?.xml ?? null;
|
||||
const totalRow = totalTemplate
|
||||
? buildTextRowFromTemplate(totalTemplate, [
|
||||
"",
|
||||
"合计",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
formatNumber(quantityTotal),
|
||||
"",
|
||||
hasAmountTotal ? formatNumber(amountTotal) : "",
|
||||
"",
|
||||
])
|
||||
: "";
|
||||
const replaceEnd = totalRowIndex ?? bodyEnd;
|
||||
const nextTable = replaceRowsInTable(
|
||||
table.tableXml,
|
||||
table.rows,
|
||||
bodyStart,
|
||||
replaceEnd + (totalTemplate ? 1 : 0),
|
||||
`${generatedRows.join("")}${totalRow}`,
|
||||
);
|
||||
|
||||
return {
|
||||
documentXml: replaceSegment(documentXml, table.start, table.end, nextTable),
|
||||
relationshipsXml: nextRelationshipsXml,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStandardModel(row: ContractFactoryRow) {
|
||||
const model = row.model.trim();
|
||||
const supplierCode = row.supplierCode.trim().toUpperCase();
|
||||
if (!supplierCode || model.toUpperCase().includes(supplierCode)) {
|
||||
return model;
|
||||
}
|
||||
return `${model}${supplierCode}`;
|
||||
}
|
||||
|
||||
function resolveOrderTotalRowIndex(rows: XmlSegment[]) {
|
||||
const lastIndex = rows.length - 1;
|
||||
const lastTexts = getCellTexts(rows[lastIndex]?.xml ?? "");
|
||||
return lastTexts.some((text) => text.includes("合计")) ? lastIndex : undefined;
|
||||
}
|
||||
|
||||
function findTableByHeaders(documentXml: string, headers: string[]) {
|
||||
const tables = collectSegments(documentXml, /<w:tbl[\s\S]*?<\/w:tbl>/g);
|
||||
for (const table of tables) {
|
||||
const rows = collectSegments(table.xml, /<w:tr[\s\S]*?<\/w:tr>/g);
|
||||
const headerIndex = rows.findIndex((row) =>
|
||||
includesHeaders(getCellTexts(row.xml), headers),
|
||||
);
|
||||
if (headerIndex >= 0) {
|
||||
return {
|
||||
tableXml: table.xml,
|
||||
start: table.start,
|
||||
end: table.end,
|
||||
rows,
|
||||
headerIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function includesHeaders(texts: string[], headers: string[]) {
|
||||
return headers.every((header) => texts.some((text) => text.includes(header)));
|
||||
}
|
||||
|
||||
function collectSegments(xml: string, pattern: RegExp): XmlSegment[] {
|
||||
const segments: XmlSegment[] = [];
|
||||
for (let match = pattern.exec(xml); match; match = pattern.exec(xml)) {
|
||||
segments.push({
|
||||
xml: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function getCellTexts(rowXml: string) {
|
||||
return collectSegments(rowXml, /<w:tc[\s\S]*?<\/w:tc>/g).map((cell) =>
|
||||
getText(cell.xml),
|
||||
);
|
||||
}
|
||||
|
||||
function getText(xml: string) {
|
||||
const textPattern = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g;
|
||||
const values: string[] = [];
|
||||
for (
|
||||
let match = textPattern.exec(xml);
|
||||
match;
|
||||
match = textPattern.exec(xml)
|
||||
) {
|
||||
values.push(unescapeXml(match[1] ?? ""));
|
||||
}
|
||||
return values.join("").trim();
|
||||
}
|
||||
|
||||
function replaceRowsInTable(
|
||||
tableXml: string,
|
||||
rows: XmlSegment[],
|
||||
startRowIndex: number,
|
||||
endRowIndexExclusive: number,
|
||||
replacementXml: string,
|
||||
) {
|
||||
const start = rows[startRowIndex]?.start;
|
||||
const end = rows[endRowIndexExclusive - 1]?.end;
|
||||
if (start === undefined || end === undefined) return tableXml;
|
||||
return replaceSegment(tableXml, start, end, replacementXml);
|
||||
}
|
||||
|
||||
function replaceSegment(xml: string, start: number, end: number, replacement: string) {
|
||||
return `${xml.slice(0, start)}${replacement}${xml.slice(end)}`;
|
||||
}
|
||||
|
||||
function buildTextRowFromTemplate(rowTemplate: string, values: string[]) {
|
||||
return buildOrderRowFromTemplate(
|
||||
rowTemplate,
|
||||
values.map((value) => ({ type: "text", value })),
|
||||
);
|
||||
}
|
||||
|
||||
function buildOrderRowFromTemplate(
|
||||
rowTemplate: string,
|
||||
values: Array<
|
||||
| { type: "text"; value: string }
|
||||
| { type: "image"; image: PreparedImage | null; alt: string }
|
||||
>,
|
||||
) {
|
||||
const cells = collectSegments(rowTemplate, /<w:tc[\s\S]*?<\/w:tc>/g);
|
||||
if (cells.length === 0) return rowTemplate;
|
||||
const builtCells = cells.map((cell, index) => {
|
||||
const nextValue = values[index] ?? { type: "text", value: "" };
|
||||
if (nextValue.type === "image") {
|
||||
return buildImageCell(cell.xml, nextValue.image, nextValue.alt);
|
||||
}
|
||||
return buildTextCell(cell.xml, nextValue.value);
|
||||
});
|
||||
return `${rowTemplate.slice(0, cells[0].start)}${builtCells.join(
|
||||
"",
|
||||
)}${rowTemplate.slice(cells[cells.length - 1].end)}`;
|
||||
}
|
||||
|
||||
function buildTextCell(cellTemplate: string, value: string) {
|
||||
const tcPr = extractTcPr(cellTemplate);
|
||||
return `<w:tc>${tcPr}<w:p><w:pPr><w:jc w:val="center"/><w:textAlignment w:val="center"/></w:pPr><w:r><w:rPr><w:rFonts w:hint="eastAsia" w:ascii="微软雅黑" w:hAnsi="微软雅黑" w:eastAsia="微软雅黑" w:cs="微软雅黑"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr><w:t xml:space="preserve">${escapeXml(
|
||||
value,
|
||||
)}</w:t></w:r></w:p></w:tc>`;
|
||||
}
|
||||
|
||||
function buildImageCell(
|
||||
cellTemplate: string,
|
||||
image: PreparedImage | null,
|
||||
alt: string,
|
||||
) {
|
||||
if (!image) return buildTextCell(cellTemplate, "");
|
||||
const tcPr = extractTcPr(cellTemplate);
|
||||
return `<w:tc>${tcPr}<w:p><w:pPr><w:jc w:val="center"/><w:textAlignment w:val="center"/></w:pPr><w:r>${buildDrawingXml(
|
||||
image,
|
||||
alt,
|
||||
)}</w:r></w:p></w:tc>`;
|
||||
}
|
||||
|
||||
function extractTcPr(cellTemplate: string) {
|
||||
return cellTemplate.match(/<w:tcPr[\s\S]*?<\/w:tcPr>/)?.[0] ?? "";
|
||||
}
|
||||
|
||||
function buildDrawingXml(image: PreparedImage, alt: string) {
|
||||
const escapedAlt = escapeXml(alt || image.fileName);
|
||||
const docPrId = image.fileName.replace(/\D+/g, "") || "1";
|
||||
return `<w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="${image.cx}" cy="${image.cy}"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:docPr id="${docPrId}" name="${escapeXml(
|
||||
image.fileName,
|
||||
)}" descr="${escapedAlt}"/><wp:cNvGraphicFramePr><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="${escapeXml(
|
||||
image.fileName,
|
||||
)}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="${image.relId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${image.cx}" cy="${image.cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing>`;
|
||||
}
|
||||
|
||||
function createImageState(relationshipsXml: string, documentXml: string) {
|
||||
return {
|
||||
relationshipsXml,
|
||||
nextRelationshipId: getNextRelationshipId(relationshipsXml),
|
||||
nextImageIndex: getNextImageIndex(relationshipsXml),
|
||||
nextDocPrId: getNextDocPrId(documentXml),
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareImage(
|
||||
zip: JSZip,
|
||||
imageSource: string,
|
||||
state: ReturnType<typeof createImageState>,
|
||||
): Promise<PreparedImage | null> {
|
||||
const sourceBuffer = await readImageSource(imageSource);
|
||||
if (!sourceBuffer) return null;
|
||||
|
||||
try {
|
||||
const converted = await sharp(sourceBuffer)
|
||||
.resize({ width: 96, height: 96, fit: "inside", withoutEnlargement: true })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const width = Math.max(1, converted.info.width || 96);
|
||||
const height = Math.max(1, converted.info.height || 96);
|
||||
const scale = Math.min(
|
||||
IMAGE_MAX_EMU / (width * PIXEL_TO_EMU),
|
||||
IMAGE_MAX_EMU / (height * PIXEL_TO_EMU),
|
||||
1,
|
||||
);
|
||||
const cx = Math.round(width * PIXEL_TO_EMU * scale);
|
||||
const cy = Math.round(height * PIXEL_TO_EMU * scale);
|
||||
const fileName = `contract-sku-${state.nextImageIndex}.png`;
|
||||
const relId = `rId${state.nextRelationshipId}`;
|
||||
|
||||
zip.file(`word/media/${fileName}`, converted.data);
|
||||
state.relationshipsXml = addImageRelationship(
|
||||
state.relationshipsXml,
|
||||
relId,
|
||||
fileName,
|
||||
);
|
||||
state.nextRelationshipId += 1;
|
||||
state.nextImageIndex += 1;
|
||||
state.nextDocPrId += 1;
|
||||
|
||||
return {
|
||||
relId,
|
||||
fileName,
|
||||
cx,
|
||||
cy,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readImageSource(imageSource: string) {
|
||||
const normalizedSource = imageSource.trim();
|
||||
if (!normalizedSource) return null;
|
||||
|
||||
const dataUrlMatch = normalizedSource.match(
|
||||
/^data:image\/[a-zA-Z0-9.+-]+;base64,([\s\S]+)$/,
|
||||
);
|
||||
if (dataUrlMatch?.[1]) {
|
||||
return Buffer.from(dataUrlMatch[1], "base64");
|
||||
}
|
||||
|
||||
const filePath = resolveNonIntegratedAssetFilePath(normalizedSource);
|
||||
if (filePath && existsSync(filePath)) {
|
||||
return readFile(filePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNextRelationshipId(relationshipsXml: string) {
|
||||
const ids = Array.from(relationshipsXml.matchAll(/Id="rId(\d+)"/g)).map(
|
||||
(match) => Number.parseInt(match[1] ?? "0", 10),
|
||||
);
|
||||
return Math.max(0, ...ids.filter(Number.isFinite)) + 1;
|
||||
}
|
||||
|
||||
function getNextImageIndex(relationshipsXml: string) {
|
||||
const indexes = Array.from(
|
||||
relationshipsXml.matchAll(/contract-sku-(\d+)\.png/g),
|
||||
).map((match) => Number.parseInt(match[1] ?? "0", 10));
|
||||
return Math.max(0, ...indexes.filter(Number.isFinite)) + 1;
|
||||
}
|
||||
|
||||
function getNextDocPrId(documentXml: string) {
|
||||
const ids = Array.from(documentXml.matchAll(/<wp:docPr[^>]*\sid="(\d+)"/g)).map(
|
||||
(match) => Number.parseInt(match[1] ?? "0", 10),
|
||||
);
|
||||
return Math.max(0, ...ids.filter(Number.isFinite)) + 1;
|
||||
}
|
||||
|
||||
function addImageRelationship(
|
||||
relationshipsXml: string,
|
||||
relId: string,
|
||||
fileName: string,
|
||||
) {
|
||||
const relationship = `<Relationship Id="${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${escapeXml(
|
||||
fileName,
|
||||
)}"/>`;
|
||||
return relationshipsXml.replace("</Relationships>", `${relationship}</Relationships>`);
|
||||
}
|
||||
|
||||
function ensurePngContentType(contentTypesXml: string) {
|
||||
if (/Extension="png"/.test(contentTypesXml)) return contentTypesXml;
|
||||
return contentTypesXml.replace(
|
||||
"</Types>",
|
||||
'<Default Extension="png" ContentType="image/png"/></Types>',
|
||||
);
|
||||
}
|
||||
|
||||
function ensureDrawingNamespaces(documentXml: string) {
|
||||
const additions: string[] = [];
|
||||
if (!documentXml.includes("xmlns:a=")) {
|
||||
additions.push(
|
||||
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"',
|
||||
);
|
||||
}
|
||||
if (!documentXml.includes("xmlns:pic=")) {
|
||||
additions.push(
|
||||
'xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"',
|
||||
);
|
||||
}
|
||||
if (additions.length === 0) return documentXml;
|
||||
|
||||
return documentXml.replace(
|
||||
/<w:document\b([^>]*)>/,
|
||||
(_match, attrs: string) => `<w:document${attrs} ${additions.join(" ")}>`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseNumber(value: string) {
|
||||
const normalized = value.trim().replace(/,/g, "");
|
||||
if (!normalized) return null;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null) {
|
||||
if (value === null || !Number.isFinite(value)) return "";
|
||||
return Number.isInteger(value)
|
||||
? String(value)
|
||||
: value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function unescapeXml(value: string) {
|
||||
return value
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
@ -4,6 +4,7 @@ import {
|
||||
NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY,
|
||||
NON_INTEGRATED_DUPLICATE_SPU_MESSAGE,
|
||||
NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES,
|
||||
applyBrandPrefixToNonIntegratedCode,
|
||||
createDefaultDocumentWorkspaceConfirmations as createSharedDocumentWorkspaceConfirmations,
|
||||
normalizeNonIntegratedProductRecord,
|
||||
resolveNonIntegratedPersistedSpu,
|
||||
@ -333,6 +334,8 @@ function buildPackagingStyleSignature(product: SharedNonIntegratedProductRecord)
|
||||
packagingModel3dDataUrl: detail.packagingModel3dDataUrl,
|
||||
packagingModel3dNodeOffsets: detail.packagingModel3dNodeOffsets,
|
||||
packagingModel3dNodeRotations: detail.packagingModel3dNodeRotations,
|
||||
packagingModel3dNodeMaterialIds: detail.packagingModel3dNodeMaterialIds,
|
||||
packagingModel3dUvConfig: detail.packagingModel3dUvConfig,
|
||||
},
|
||||
]),
|
||||
)
|
||||
@ -738,7 +741,7 @@ function buildHydratedProductRecord(
|
||||
? payloadRecord.updatedBy
|
||||
: undefined
|
||||
|
||||
return {
|
||||
return normalizeSharedProductSpuByBrand({
|
||||
...payloadRecord,
|
||||
...normalizedProduct,
|
||||
uploadFiles: mergeUploadFileMetadata(
|
||||
@ -773,7 +776,7 @@ function buildHydratedProductRecord(
|
||||
: payloadUpdatedBy,
|
||||
createdAt: timestamps?.createdAt?.toISOString() ?? normalizedProduct.createdAt,
|
||||
updatedAt: timestamps?.updatedAt?.toISOString() ?? normalizedProduct.updatedAt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function hasMeaningfulBomDetails(
|
||||
@ -787,6 +790,48 @@ function hasMeaningfulBomDetails(
|
||||
)
|
||||
}
|
||||
|
||||
function shouldApplyBrandPrefixToPersistedSpu(
|
||||
product: SharedNonIntegratedProductRecord,
|
||||
resolvedSpu: string,
|
||||
) {
|
||||
const resolvedBaseSpu = stripNonIntegratedBrandPrefix(resolvedSpu)
|
||||
const temporaryCodeBase = stripNonIntegratedBrandPrefix(
|
||||
product.openingReference?.temporaryCode ?? '',
|
||||
)
|
||||
const productCodeBase = stripNonIntegratedBrandPrefix(product.productCode ?? '')
|
||||
|
||||
return (
|
||||
product.formalSpuAssigned === true ||
|
||||
(temporaryCodeBase.length > 0 && resolvedBaseSpu === temporaryCodeBase) ||
|
||||
(productCodeBase.length > 0 && resolvedBaseSpu === productCodeBase)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSharedProductSpuByBrand(
|
||||
product: SharedNonIntegratedProductRecord,
|
||||
): SharedNonIntegratedProductRecord {
|
||||
const resolvedSpu = resolveNonIntegratedPersistedSpu(product)
|
||||
const nextSpu = shouldApplyBrandPrefixToPersistedSpu(product, resolvedSpu)
|
||||
? applyBrandPrefixToNonIntegratedCode(resolvedSpu, product.brand)
|
||||
: resolvedSpu
|
||||
const productCode = product.productCode?.trim() ?? ''
|
||||
const nextProductCode =
|
||||
nextSpu &&
|
||||
(!productCode ||
|
||||
stripNonIntegratedBrandPrefix(productCode) ===
|
||||
stripNonIntegratedBrandPrefix(resolvedSpu) ||
|
||||
stripNonIntegratedBrandPrefix(productCode) ===
|
||||
stripNonIntegratedBrandPrefix(nextSpu))
|
||||
? nextSpu
|
||||
: productCode
|
||||
|
||||
return {
|
||||
...product,
|
||||
spu: nextSpu,
|
||||
productCode: nextProductCode,
|
||||
}
|
||||
}
|
||||
|
||||
function buildPersistedProductPayload(
|
||||
productInput: unknown,
|
||||
existingPayload?: unknown,
|
||||
@ -877,7 +922,7 @@ function buildPersistedProductPayload(
|
||||
? existingRecord.updatedBy
|
||||
: undefined
|
||||
|
||||
return {
|
||||
return normalizeSharedProductSpuByBrand({
|
||||
...existingRecord,
|
||||
...incomingRecord,
|
||||
...normalizedProduct,
|
||||
@ -893,7 +938,7 @@ function buildPersistedProductPayload(
|
||||
...(createdBy ? { createdBy } : {}),
|
||||
...(updatedBy ? { updatedBy } : {}),
|
||||
createdAt: existingCreatedAt ?? normalizedProduct.createdAt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeUploadCategory(value: string | undefined) {
|
||||
@ -1202,40 +1247,114 @@ function buildPeopleSearchWhere(
|
||||
)
|
||||
}
|
||||
|
||||
function hasListBusinessRole(user: AuthUser, role: string) {
|
||||
return Boolean(user.businessRoles?.some((item) => item === role))
|
||||
}
|
||||
|
||||
function isListAdminUser(user: AuthUser) {
|
||||
return user.role === 'SUPER_ADMIN' || user.role === 'ADMIN'
|
||||
}
|
||||
|
||||
function getListDepartmentText(user: AuthUser) {
|
||||
return [
|
||||
user.department?.name,
|
||||
user.department?.fullPath,
|
||||
...(user.departmentPaths ?? []),
|
||||
]
|
||||
.map((value) => value?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function isListSpaceDesignUser(user: AuthUser) {
|
||||
const departmentText = getListDepartmentText(user)
|
||||
|
||||
return hasListBusinessRole(user, 'DESIGN') && /空间|视觉/.test(departmentText)
|
||||
}
|
||||
|
||||
function isListOperationsOwnerUser(user: AuthUser) {
|
||||
return /运营/.test(getListDepartmentText(user))
|
||||
}
|
||||
|
||||
function buildResponsibleOwnerSearchWhere(
|
||||
user: AuthUser,
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput | null {
|
||||
if (isListAdminUser(user)) return null
|
||||
|
||||
const userName = user.name.trim()
|
||||
if (!userName) return null
|
||||
|
||||
const conditions: Prisma.IntakeNonIntegratedProductWhereInput[] = []
|
||||
|
||||
if (hasListBusinessRole(user, 'DESIGN')) {
|
||||
if (isListSpaceDesignUser(user)) {
|
||||
conditions.push(
|
||||
jsonTextContains(['visualPlanning'], userName),
|
||||
jsonTextContains(['visualDirector'], userName),
|
||||
)
|
||||
} else {
|
||||
conditions.push(
|
||||
{ designer: { contains: userName, mode: 'insensitive' } },
|
||||
jsonTextContains(['designer'], userName),
|
||||
jsonTextContains(['openingReference', 'designer'], userName),
|
||||
jsonTextContains(['openingReference', 'structureDesigner'], userName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasListBusinessRole(user, 'PLANNING')) {
|
||||
conditions.push(
|
||||
jsonTextContains(['productPlanning'], userName),
|
||||
jsonTextContains(['openingReference', 'planningOwner'], userName),
|
||||
)
|
||||
}
|
||||
|
||||
if (hasListBusinessRole(user, 'PURCHASING')) {
|
||||
conditions.push(
|
||||
{ purchaser: { contains: userName, mode: 'insensitive' } },
|
||||
jsonTextContains(['purchaseOwner'], userName),
|
||||
)
|
||||
}
|
||||
|
||||
if (isListOperationsOwnerUser(user)) {
|
||||
conditions.push(
|
||||
{ operator: { contains: userName, mode: 'insensitive' } },
|
||||
jsonTextContains(['spuOwner'], userName),
|
||||
jsonTextContains(['operationsOwner'], userName),
|
||||
)
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
conditions.push(
|
||||
{ designer: { contains: userName, mode: 'insensitive' } },
|
||||
{ operator: { contains: userName, mode: 'insensitive' } },
|
||||
{ purchaser: { contains: userName, mode: 'insensitive' } },
|
||||
jsonTextContains(['designer'], userName),
|
||||
jsonTextContains(['openingReference', 'designer'], userName),
|
||||
jsonTextContains(['openingReference', 'structureDesigner'], userName),
|
||||
jsonTextContains(['spuOwner'], userName),
|
||||
jsonTextContains(['operationsOwner'], userName),
|
||||
jsonTextContains(['productPlanning'], userName),
|
||||
jsonTextContains(['openingReference', 'planningOwner'], userName),
|
||||
jsonTextContains(['visualPlanning'], userName),
|
||||
jsonTextContains(['visualDirector'], userName),
|
||||
jsonTextContains(['purchaseOwner'], userName),
|
||||
)
|
||||
}
|
||||
|
||||
return { OR: conditions }
|
||||
}
|
||||
|
||||
function buildQuickScopeWhere(
|
||||
quickScope: IntakeNonIntegratedProductListParams['quickScope'],
|
||||
user: AuthUser,
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput | null {
|
||||
if (quickScope === 'created') {
|
||||
const creatorConditions: Prisma.IntakeNonIntegratedProductWhereInput[] = [
|
||||
{ createdBy: user.id },
|
||||
]
|
||||
if (user.name.trim()) {
|
||||
creatorConditions.push({ createdBy: user.name.trim() })
|
||||
}
|
||||
|
||||
return { OR: creatorConditions }
|
||||
return buildResponsibleOwnerSearchWhere(user)
|
||||
}
|
||||
|
||||
if (quickScope === 'pending' && user.name.trim()) {
|
||||
const userName = user.name.trim()
|
||||
return {
|
||||
OR: [
|
||||
{ designer: { contains: userName, mode: 'insensitive' } },
|
||||
{ operator: { contains: userName, mode: 'insensitive' } },
|
||||
{ purchaser: { contains: userName, mode: 'insensitive' } },
|
||||
jsonTextContains(['designer'], userName),
|
||||
jsonTextContains(['openingReference', 'designer'], userName),
|
||||
jsonTextContains(['openingReference', 'structureDesigner'], userName),
|
||||
jsonTextContains(['spuOwner'], userName),
|
||||
jsonTextContains(['operationsOwner'], userName),
|
||||
jsonTextContains(['productPlanning'], userName),
|
||||
jsonTextContains(['openingReference', 'planningOwner'], userName),
|
||||
jsonTextContains(['visualPlanning'], userName),
|
||||
jsonTextContains(['visualDirector'], userName),
|
||||
jsonTextContains(['purchaseOwner'], userName),
|
||||
],
|
||||
}
|
||||
if (quickScope === 'pending') {
|
||||
return buildResponsibleOwnerSearchWhere(user)
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@ -472,6 +472,8 @@ function buildSourceProductPayload(
|
||||
productImageName: imageName,
|
||||
size,
|
||||
material,
|
||||
barcode: firstSku?.barcode ?? '',
|
||||
reviewResult: '',
|
||||
color,
|
||||
issues: [],
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user