Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-mainline

This commit is contained in:
WuYongJie 2026-05-22 18:49:52 +08:00
commit 9b5dbbe856
23 changed files with 6760 additions and 798 deletions

View File

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

View File

@ -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 相关样式 */

View File

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

View File

@ -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}
/>
)
}

View File

@ -58,6 +58,8 @@ function createProduct(overrides?: Partial<NonIntegratedProductRecord>): NonInte
productImageName: '',
size: '',
material: '',
barcode: '',
reviewResult: '',
color: '',
issues: [],
},

View File

@ -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: [],

View File

@ -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,18 +1825,57 @@ function ModelViewerComponent({
}
const feedbackLabels = buildTransformFeedbackLabels(activeNodeName);
if (offset && onNodePositionOffsetChange) {
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: activeNodeName,
offset,
nodeName,
offset: nextOffset,
});
}
if (rotation && onNodeRotationChange) {
if (nextRotation && onNodeRotationChange) {
onNodeRotationChange({
nodeName: activeNodeName,
rotation,
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,

View File

@ -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();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function unescapeXml(value: string) {
return value
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}

View File

@ -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,25 +1247,85 @@ function buildPeopleSearchWhere(
)
}
function buildQuickScopeWhere(
quickScope: IntakeNonIntegratedProductListParams['quickScope'],
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 (quickScope === 'created') {
const creatorConditions: Prisma.IntakeNonIntegratedProductWhereInput[] = [
{ createdBy: user.id },
]
if (user.name.trim()) {
creatorConditions.push({ createdBy: user.name.trim() })
}
if (isListAdminUser(user)) return null
return { OR: creatorConditions }
}
if (quickScope === 'pending' && user.name.trim()) {
const userName = user.name.trim()
return {
OR: [
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' } },
@ -1234,8 +1339,22 @@ function buildQuickScopeWhere(
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') {
return buildResponsibleOwnerSearchWhere(user)
}
if (quickScope === 'pending') {
return buildResponsibleOwnerSearchWhere(user)
}
return null

View File

@ -472,6 +472,8 @@ function buildSourceProductPayload(
productImageName: imageName,
size,
material,
barcode: firstSku?.barcode ?? '',
reviewResult: '',
color,
issues: [],
},