This commit is contained in:
2026-05-14 16:56:18 +08:00
parent d38a4630dd
commit 224330598a
15 changed files with 1151 additions and 277 deletions
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -39,7 +39,7 @@ alembic>=1.11.0
# 对象存储 # 对象存储
# ============================================ # ============================================
minio>=7.1.0 minio>=7.1.0
aiohttp>=3.8.0 aiohttp>=3.13.4
# ============================================ # ============================================
# 消息队列 # 消息队列
@@ -62,6 +62,7 @@ aiofiles>=23.0.0
orjson>=3.9.0 orjson>=3.9.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
jinja2>=3.1.0 jinja2>=3.1.0
Pillow>=12.2.0
pyyaml>=6.0 pyyaml>=6.0
python-dateutil>=2.8.0 python-dateutil>=2.8.0
+10 -21
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from services.auth_service import get_current_active_user from services.auth_service import get_current_active_user
from services.redis_task_manager import redis_task_manager from services.redis_task_manager import redis_task_manager
from services.processing_service import processing_service
from models.database import User from models.database import User
from utils.logger import get_logger from utils.logger import get_logger
@@ -281,6 +282,7 @@ async def export_mold_results(
): ):
body = await request.json() body = await request.json()
task_id = body.get("task_id") task_id = body.get("task_id")
scheme_id = body.get("scheme_id")
formats = body.get("formats", ["step", "stl"]) formats = body.get("formats", ["step", "stl"])
components = body.get("components", ["cavity", "core"]) components = body.get("components", ["cavity", "core"])
@@ -291,16 +293,17 @@ async def export_mold_results(
if not task_data: if not task_data:
raise HTTPException(404, "任务不存在") raise HTTPException(404, "任务不存在")
cavity_shapes = task_data.get("cavity_shapes") cavity_shapes = processing_service.get_export_shapes(
task_id,
scheme_id or task_data.get("best_scheme_id"),
)
filename = task_data.get("filename", f"mold_{task_id}") filename = task_data.get("filename", f"mold_{task_id}")
if not cavity_shapes: if not cavity_shapes:
file_path = task_data.get("file_path") raise HTTPException(
if file_path and os.path.exists(str(file_path)): 409,
cavity_shapes = await _reparse_stp_for_export(str(file_path), task_data.get("material", "ABS")) "导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
)
if not cavity_shapes:
raise HTTPException(400, "该任务尚未完成模具生成或形状数据不可用")
exporter = _get_cached("cad_exporter") exporter = _get_cached("cad_exporter")
if not exporter: if not exporter:
@@ -352,17 +355,3 @@ async def get_export_recommendations(
return {"status": "success", "data": result} return {"status": "success", "data": result}
async def _reparse_stp_for_export(file_path: str, material: str = "ABS") -> dict:
try:
from core.stp_parser import STPParser
from core.mold_generator import MoldCavityGenerator
stp_parser = STPParser()
shape = stp_parser.load_step_file(Path(file_path))
mold_gen = MoldCavityGenerator(shrinkage_rate=0.005)
mold_gen.set_material(material)
cavity_result = mold_gen.generate_mold_cavities(shape)
logger.info(f"重新解析 STP 用于导出: {file_path}")
return cavity_result
except Exception as e:
logger.warning(f"重新解析 STP 导出失败: {e}")
return None
+34 -10
View File
@@ -1,6 +1,5 @@
# api/v1/upload_router.py # api/v1/upload_router.py
from fastapi import APIRouter, UploadFile, File, HTTPException, BackgroundTasks, Depends from fastapi import APIRouter, UploadFile, File, HTTPException, BackgroundTasks, Depends, Form
from typing import Optional
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -27,14 +26,25 @@ file_handler = FileHandler()
async def upload_stp( async def upload_stp(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
file: UploadFile = File(...), file: UploadFile = File(...),
material: Optional[str] = "ABS", material: str = Form(...),
draft_angle: float = Form(...),
shrinkage_rate: float = Form(...),
parting_precision: float = Form(...),
cavity_match: int = Form(...),
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
"""上传STP文件并存储到数据库""" """上传STP文件并存储到数据库"""
process_params = {
"material": material,
"draft_angle": float(draft_angle),
"shrinkage_rate": float(shrinkage_rate),
"parting_precision": float(parting_precision),
"cavity_match": int(cavity_match),
}
logger.info( logger.info(
f"[UPLOAD] 用户={current_user.username}(id={current_user.id}) " f"[UPLOAD] 用户={current_user.username}(id={current_user.id}) "
f"文件={file.filename} 材料={material} " f"文件={file.filename} 参数={process_params} "
f"大小={file.size if hasattr(file, 'size') else 'unknown'}" f"大小={file.size if hasattr(file, 'size') else 'unknown'}"
) )
@@ -44,7 +54,11 @@ async def upload_stp(
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
file_path, file_size = await file_handler.save_uploaded_file(file) try:
file_path, file_size, file_meta = await file_handler.save_uploaded_file(file)
except ValueError as exc:
logger.warning(f"[UPLOAD] 拒绝非法文件: {file.filename}, 原因={exc}")
raise HTTPException(400, str(exc)) from exc
logger.info(f"[UPLOAD] 文件已保存: {file_path} ({file_size} bytes), task_id={task_id}") logger.info(f"[UPLOAD] 文件已保存: {file_path} ({file_size} bytes), task_id={task_id}")
storage_service = StorageIntegrationService() storage_service = StorageIntegrationService()
@@ -52,12 +66,17 @@ async def upload_stp(
stp_file = await storage_service.save_stp_file( stp_file = await storage_service.save_stp_file(
session=db_session, session=db_session,
file_path=file_path, file_path=file_path,
original_filename=file.filename, original_filename=file_meta["safe_original_name"],
user_id=current_user.id user_id=current_user.id
) )
logger.info(f"[UPLOAD] STP文件已存入RustFS+PG: stp_file.id={stp_file.id}") logger.info(f"[UPLOAD] STP文件已存入RustFS+PG: stp_file.id={stp_file.id}")
await storage_service.create_processing_task(db_session, task_id, stp_file.id) await storage_service.create_processing_task(
db_session,
task_id,
stp_file.id,
parameters=process_params,
)
task_info = create_task_info( task_info = create_task_info(
task_id=task_id, task_id=task_id,
@@ -67,11 +86,14 @@ async def upload_stp(
file_size=file_size, file_size=file_size,
upload_time=str(datetime.now()) upload_time=str(datetime.now())
) )
task_info["material"] = material
task_info["parameters"] = process_params
task_info["file_hash"] = file_meta["sha256"]
await redis_task_manager.set_task(task_id, task_info) await redis_task_manager.set_task(task_id, task_info)
background_tasks.add_task( background_tasks.add_task(
processing_service.process_file_with_storage, processing_service.process_file_with_storage,
task_id, file_path, stp_file.id, material task_id, file_path, stp_file.id, process_params
) )
logger.info(f"[UPLOAD] 后台处理已调度: task_id={task_id}") logger.info(f"[UPLOAD] 后台处理已调度: task_id={task_id}")
@@ -83,6 +105,8 @@ async def upload_stp(
"filename": file.filename, "filename": file.filename,
"size": file_size, "size": file_size,
"pythonocc_available": True, "pythonocc_available": True,
"database_file_id": stp_file.id "database_file_id": stp_file.id,
} "sha256": file_meta["sha256"],
},
"parameters": process_params,
} }
+24
View File
@@ -35,9 +35,11 @@ class MultiSchemeMoldPlanner:
material: Dict[str, Any], material: Dict[str, Any],
is_foam_material: bool = False, is_foam_material: bool = False,
max_schemes: int = 3, max_schemes: int = 3,
process_params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
generator = self.aluminum_foam_generator if is_foam_material else self.mold_generator generator = self.aluminum_foam_generator if is_foam_material else self.mold_generator
generator.set_material(material["name"]) generator.set_material(material["name"])
self._apply_process_params(generator, material, process_params)
analysis = generator._analyze_product_geometry(shape) analysis = generator._analyze_product_geometry(shape)
analysis["axis_normal_stats"] = self._collect_axis_normal_stats(generator, shape) analysis["axis_normal_stats"] = self._collect_axis_normal_stats(generator, shape)
@@ -67,16 +69,20 @@ class MultiSchemeMoldPlanner:
raise ValueError("未能生成任何可用分模方案") raise ValueError("未能生成任何可用分模方案")
scored_schemes = self.scheme_scorer.score_schemes(schemes)[:max_schemes] scored_schemes = self.scheme_scorer.score_schemes(schemes)[:max_schemes]
export_shapes = {}
for idx, scheme in enumerate(scored_schemes, start=1): for idx, scheme in enumerate(scored_schemes, start=1):
scheme["raw_scheme_id"] = scheme.get("scheme_id") scheme["raw_scheme_id"] = scheme.get("scheme_id")
scheme["scheme_id"] = f"scheme_{idx}" scheme["scheme_id"] = f"scheme_{idx}"
if scheme.get("cavity_data", {}).get("metadata") is not None: if scheme.get("cavity_data", {}).get("metadata") is not None:
scheme["cavity_data"]["metadata"]["scheme_id"] = scheme["scheme_id"] scheme["cavity_data"]["metadata"]["scheme_id"] = scheme["scheme_id"]
scheme["cavity_data"]["metadata"]["process_parameters"] = dict(process_params or {})
export_shapes[scheme["scheme_id"]] = scheme.pop("_export_shapes", {})
best_scheme = scored_schemes[0] best_scheme = scored_schemes[0]
return { return {
"best_scheme_id": best_scheme["scheme_id"], "best_scheme_id": best_scheme["scheme_id"],
"candidate_schemes": scored_schemes, "candidate_schemes": scored_schemes,
"_export_shapes": export_shapes,
"global_summary": { "global_summary": {
"scheme_count": len(scored_schemes), "scheme_count": len(scored_schemes),
"recommended_reason": best_scheme.get("summary", ""), "recommended_reason": best_scheme.get("summary", ""),
@@ -178,8 +184,26 @@ class MultiSchemeMoldPlanner:
"side_actions": side_action_result, "side_actions": side_action_result,
"cavity_data": cavity_data, "cavity_data": cavity_data,
"key_info": key_info, "key_info": key_info,
"_export_shapes": {
"cavity": cavity,
"core": core,
"parting_surface": parting_surface,
},
} }
@staticmethod
def _apply_process_params(generator: Any, material: Dict[str, Any], process_params: Optional[Dict[str, Any]]):
params = process_params or {}
draft_angle = float(params.get("draft_angle", getattr(generator, "draft_angle", 2.0)))
shrinkage_rate = float(params.get("shrinkage_rate", material.get("shrinkage", 0.005) * 100.0)) / 100.0
parting_precision = float(params.get("parting_precision", getattr(generator, "parting_line_tolerance", 0.1)))
cavity_match = float(params.get("cavity_match", getattr(generator, "cavity_match_rate", 95.0)))
generator.draft_angle = draft_angle
generator.shrinkage_rate = shrinkage_rate
generator.parting_line_tolerance = parting_precision
generator.cavity_match_rate = cavity_match
def _build_parting_surface( def _build_parting_surface(
self, self,
generator: Any, generator: Any,
+1 -6
View File
@@ -74,15 +74,10 @@ async def log_requests(request: Request, call_next):
status = response.status_code status = response.status_code
if status >= 400: if status >= 400:
auth_header = request.headers.get("authorization", "")
token_preview = ""
if auth_header.startswith("Bearer "):
token_raw = auth_header[7:]
token_preview = token_raw[:20] + "..." if len(token_raw) > 20 else token_raw
logger.warning( logger.warning(
f"[HTTP] {request.method} {request.url.path} -> {status} " f"[HTTP] {request.method} {request.url.path} -> {status} "
f"({duration:.2f}s) " f"({duration:.2f}s) "
f"token={token_preview or 'none'}" f"client={request.client.host if request.client else 'unknown'}"
) )
return response return response
+49 -1
View File
@@ -316,7 +316,7 @@ class CalculationService:
file_path=file_path, file_path=file_path,
cavity_mesh_data=None, cavity_mesh_data=None,
) )
return { result = {
"best_scheme_id": "scheme_1", "best_scheme_id": "scheme_1",
"candidate_schemes": [ "candidate_schemes": [
{ {
@@ -349,6 +349,8 @@ class CalculationService:
"cavity_data": legacy, "cavity_data": legacy,
"key_info": legacy.get("mold_cavities", {}).get("cavity_key_info", {}), "key_info": legacy.get("mold_cavities", {}).get("cavity_key_info", {}),
} }
cls.attach_injection_system_summaries(result, material["name"])
return result
candidate_schemes = plan_result.get("candidate_schemes", []) candidate_schemes = plan_result.get("candidate_schemes", [])
best_scheme = cls.get_best_scheme(plan_result) best_scheme = cls.get_best_scheme(plan_result)
@@ -359,8 +361,54 @@ class CalculationService:
"cavity_data": best_scheme.get("cavity_data", {}) if best_scheme else {}, "cavity_data": best_scheme.get("cavity_data", {}) if best_scheme else {},
"key_info": best_scheme.get("key_info", {}) if best_scheme else {}, "key_info": best_scheme.get("key_info", {}) if best_scheme else {},
} }
cls.attach_injection_system_summaries(result, material["name"])
return result return result
@classmethod
def attach_injection_system_summaries(
cls,
plan_result: Dict[str, Any],
material_name: str,
) -> Dict[str, Any]:
"""为每个候选方案补充注塑模冷却/浇注摘要。"""
from core.mold_system_designer import MoldSystemDesigner
designer = MoldSystemDesigner()
for scheme in plan_result.get("candidate_schemes", []):
cavity_data = scheme.get("cavity_data") or {}
product_bbox = cavity_data.get("product_analysis", {}).get("bounding_box", {})
mold_size = cavity_data.get("manufacturing_info", {}).get("estimated_mold_size", {})
cavity_count = cavity_data.get("mold_cavities", {}).get("cavity_count", 1)
if not product_bbox or not mold_size:
continue
system_result = designer.design_complete_system(
mold_size=mold_size,
product_bbox=product_bbox,
material=material_name,
cavity_count=cavity_count,
)
cavity_data["injection_system"] = system_result
cavity_data.setdefault("manufacturing_info", {})
cavity_data["manufacturing_info"]["cooling_summary"] = {
"cooling_time": system_result.get("cooling", {}).get("cooling_time"),
"channel_count": system_result.get("cooling", {}).get("thermal_check", {}).get("channel_count"),
"flow_rate_lpm": system_result.get("cooling", {}).get("flow_rate", {}).get("flow_rate_lpm"),
}
cavity_data["manufacturing_info"]["gating_summary"] = {
"gate_type": system_result.get("gating", {}).get("gate_type"),
"runner_type": system_result.get("gating", {}).get("runner", {}).get("type"),
"estimated_cycle_time": system_result.get("overall_assessment", {}).get("estimated_cycle_time"),
}
best_scheme = cls.get_best_scheme(plan_result)
if best_scheme:
plan_result["injection_system"] = best_scheme.get("cavity_data", {}).get("injection_system")
return plan_result
@staticmethod @staticmethod
def get_best_scheme(plan_result: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: def get_best_scheme(plan_result: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if not plan_result: if not plan_result:
+75 -8
View File
@@ -2,6 +2,7 @@
"""STP 文件处理流程编排器 — 协调解析、网格生成、型腔生成、保存、验证""" """STP 文件处理流程编排器 — 协调解析、网格生成、型腔生成、保存、验证"""
import asyncio import asyncio
import time
import traceback import traceback
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -42,6 +43,7 @@ class ProcessingService:
self.html_generator = HTMLGenerator() self.html_generator = HTMLGenerator()
self.storage_service = StorageIntegrationService() self.storage_service = StorageIntegrationService()
self.multi_scheme_planner = MultiSchemeMoldPlanner() self.multi_scheme_planner = MultiSchemeMoldPlanner()
self._export_shapes_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
# ─── 对外入口 ─── # ─── 对外入口 ───
@@ -50,7 +52,7 @@ class ProcessingService:
task_id: str, task_id: str,
file_path: str, file_path: str,
stp_file_id: int, stp_file_id: int,
material: str = "ABS", process_params: Optional[Dict[str, Any]] = None,
): ):
"""处理文件的后台任务 — 使用独立数据库会话""" """处理文件的后台任务 — 使用独立数据库会话"""
@@ -65,7 +67,7 @@ class ProcessingService:
try: try:
await asyncio.wait_for( await asyncio.wait_for(
self.process_file_core( self.process_file_core(
task_id, file_path, stp_file_id, db_session, material task_id, file_path, stp_file_id, db_session, process_params
), ),
timeout_seconds, timeout_seconds,
) )
@@ -96,29 +98,35 @@ class ProcessingService:
file_path: str, file_path: str,
stp_file_id: int, stp_file_id: int,
db_session: AsyncSession, db_session: AsyncSession,
material: str = "ABS", process_params: Optional[Dict[str, Any]] = None,
): ):
"""核心处理逻辑""" """核心处理逻辑"""
try: try:
logger.info(f"开始处理文件并生成模具型腔: {file_path}") logger.info(f"开始处理文件并生成模具型腔: {file_path}")
process_params = self._normalize_process_params(process_params)
stage_timings: Dict[str, float] = {}
# 1. 解析STP文件 # 1. 解析STP文件
await self.storage_service.update_task_status( await self.storage_service.update_task_status(
db_session, task_id, "processing", 20, "解析STP文件" db_session, task_id, "processing", 20, "解析STP文件"
) )
stage_started = time.perf_counter()
shape = self.stp_parser.load_step_file(Path(file_path)) shape = self.stp_parser.load_step_file(Path(file_path))
geometry_data = self.stp_parser.analyze_geometry(shape) geometry_data = self.stp_parser.analyze_geometry(shape)
stage_timings["parse_stp"] = round(time.perf_counter() - stage_started, 3)
# 2. 生成网格数据并持久化 # 2. 生成网格数据并持久化
await self.storage_service.update_task_status( await self.storage_service.update_task_status(
db_session, task_id, "processing", 30, "生成网格数据" db_session, task_id, "processing", 30, "生成网格数据"
) )
stage_started = time.perf_counter()
mesh_result = await self._step_generate_mesh( mesh_result = await self._step_generate_mesh(
shape, geometry_data, file_path, db_session, stp_file_id, task_id shape, geometry_data, file_path, db_session, stp_file_id, task_id
) )
stage_timings["generate_mesh"] = round(time.perf_counter() - stage_started, 3)
# 3. 生成模具型腔 # 3. 生成模具型腔
await self.storage_service.update_task_status( await self.storage_service.update_task_status(
@@ -126,25 +134,35 @@ class ProcessingService:
) )
# 材料属性 — 通过 MaterialService 集中管理 # 材料属性 — 通过 MaterialService 集中管理
requested_material = MaterialService.resolve_material(material) requested_material = MaterialService.resolve_material(process_params["material"])
selected_material = MaterialService.get_material(requested_material) selected_material = dict(MaterialService.get_material(requested_material))
selected_material["shrinkage"] = process_params["shrinkage_rate"] / 100.0
is_foam_material = MaterialService.is_foam_material(requested_material) is_foam_material = MaterialService.is_foam_material(requested_material)
stage_started = time.perf_counter()
plan_result = await self._step_generate_cavity( plan_result = await self._step_generate_cavity(
shape, selected_material, is_foam_material shape, selected_material, is_foam_material, process_params
) )
stage_timings["generate_cavity"] = round(time.perf_counter() - stage_started, 3)
export_shapes = {}
if plan_result:
export_shapes = plan_result.pop("_export_shapes", {}) or {}
if export_shapes:
self._cache_export_shapes(task_id, export_shapes)
# 4. 生成详细JSON数据 — 委托 CalculationService # 4. 生成详细JSON数据 — 委托 CalculationService
await self.storage_service.update_task_status( await self.storage_service.update_task_status(
db_session, task_id, "processing", 60, "生成型腔详细数据" db_session, task_id, "processing", 60, "生成型腔详细数据"
) )
stage_started = time.perf_counter()
detailed_cavity_json = CalculationService.build_plan_result( detailed_cavity_json = CalculationService.build_plan_result(
geometry_data=geometry_data, geometry_data=geometry_data,
material=selected_material, material=selected_material,
file_path=str(file_path), file_path=str(file_path),
plan_result=plan_result, plan_result=plan_result,
) )
stage_timings["build_plan_result"] = round(time.perf_counter() - stage_started, 3)
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json) best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else {} best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else {}
@@ -164,6 +182,7 @@ class ProcessingService:
db_session, task_id, "processing", 70, "保存几何数据" db_session, task_id, "processing", 70, "保存几何数据"
) )
stage_started = time.perf_counter()
await self.storage_service.save_geometry_data( await self.storage_service.save_geometry_data(
db_session, db_session,
stp_file_id, stp_file_id,
@@ -229,9 +248,15 @@ class ProcessingService:
Path(html_file_path).name, Path(html_file_path).name,
html_file_path, html_file_path,
) )
stage_timings["persist_artifacts"] = round(time.perf_counter() - stage_started, 3)
# 9. 分析模具设计 # 9. 分析模具设计
analysis_result = self.geometry_analyzer.analyze_mold_design(geometry_data) stage_started = time.perf_counter()
analysis_result = self.geometry_analyzer.analyze_mold_design(
geometry_data,
product_material=requested_material,
shape=shape,
)
if analysis_result: if analysis_result:
await self.storage_service.save_features_and_recommendations( await self.storage_service.save_features_and_recommendations(
@@ -242,6 +267,7 @@ class ProcessingService:
) )
await self._save_analysis_metrics(db_session, stp_file_id, analysis_result) await self._save_analysis_metrics(db_session, stp_file_id, analysis_result)
stage_timings["analyze_design"] = round(time.perf_counter() - stage_started, 3)
# 9.6 更新STP文件的分析摘要字段 # 9.6 更新STP文件的分析摘要字段
await self.storage_service.update_stp_file_analysis_summary( await self.storage_service.update_stp_file_analysis_summary(
@@ -255,20 +281,35 @@ class ProcessingService:
) )
# 9.7 FreeCAD 几何验证 # 9.7 FreeCAD 几何验证
stage_started = time.perf_counter()
verification_result = await self._step_verify( verification_result = await self._step_verify(
file_path, db_session, task_id, stp_file_id, analysis_result file_path, db_session, task_id, stp_file_id, analysis_result
) )
stage_timings["verify_geometry"] = round(time.perf_counter() - stage_started, 3)
# 9.8 LLM 增强分析 # 9.8 LLM 增强分析
llm_report = None llm_report = None
stage_started = time.perf_counter()
if analysis_result: if analysis_result:
llm_report = await llm_service.generate_design_report(analysis_result, detailed_cavity_json) llm_report = await llm_service.generate_design_report(analysis_result, detailed_cavity_json)
stage_timings["generate_llm_report"] = round(time.perf_counter() - stage_started, 3)
# 10. 完成处理 # 10. 完成处理
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "completed") await self.storage_service.update_stp_file_status(db_session, stp_file_id, "completed")
await self.storage_service.update_task_status( await self.storage_service.update_task_status(
db_session, task_id, "completed", 100, "模具型腔生成完成" db_session, task_id, "completed", 100, "模具型腔生成完成"
) )
await self.storage_service.update_task_parameters(
db_session,
task_id,
{
"stage_timings": stage_timings,
"material": requested_material,
"verification": verification_result,
"llm_report": llm_report,
**process_params,
},
)
# 更新任务缓存状态 # 更新任务缓存状态
await redis_task_manager.update_task(task_id, { await redis_task_manager.update_task(task_id, {
@@ -279,6 +320,9 @@ class ProcessingService:
"best_scheme_id": detailed_cavity_json.get("best_scheme_id"), "best_scheme_id": detailed_cavity_json.get("best_scheme_id"),
"cavity_data": best_cavity_data, "cavity_data": best_cavity_data,
"key_info": best_key_info, "key_info": best_key_info,
"material": requested_material,
"parameters": process_params,
"stage_timings": stage_timings,
"html_file": best_scheme.get("html_file", f"/html/{Path(html_file_path).name}") if best_scheme else f"/html/{Path(html_file_path).name}", "html_file": best_scheme.get("html_file", f"/html/{Path(html_file_path).name}") if best_scheme else f"/html/{Path(html_file_path).name}",
"verification": verification_result, "verification": verification_result,
"llm_report": llm_report, "llm_report": llm_report,
@@ -370,7 +414,7 @@ class ProcessingService:
return mesh_result return mesh_result
async def _step_generate_cavity( async def _step_generate_cavity(
self, shape, selected_material: dict, is_foam_material: bool, self, shape, selected_material: dict, is_foam_material: bool, process_params: Dict[str, Any],
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""生成多方案分模结果""" """生成多方案分模结果"""
plan_result = None plan_result = None
@@ -380,6 +424,7 @@ class ProcessingService:
shape=shape, shape=shape,
material=selected_material, material=selected_material,
is_foam_material=is_foam_material, is_foam_material=is_foam_material,
process_params=process_params,
) )
logger.info( logger.info(
f"多方案分模完成: 生成 {len(plan_result.get('candidate_schemes', []))} 套方案" f"多方案分模完成: 生成 {len(plan_result.get('candidate_schemes', []))} 套方案"
@@ -391,6 +436,28 @@ class ProcessingService:
return plan_result return plan_result
def _cache_export_shapes(self, task_id: str, export_shapes: Dict[str, Dict[str, Any]]):
self._export_shapes_cache[task_id] = export_shapes
def get_export_shapes(self, task_id: str, scheme_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
scheme_map = self._export_shapes_cache.get(task_id, {})
if not scheme_map:
return None
if scheme_id:
return scheme_map.get(scheme_id)
return next(iter(scheme_map.values()), None)
@staticmethod
def _normalize_process_params(process_params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
payload = dict(process_params or {})
return {
"material": MaterialService.resolve_material(str(payload.get("material", "ABS"))),
"draft_angle": float(payload.get("draft_angle", 2.0)),
"shrinkage_rate": float(payload.get("shrinkage_rate", 0.5)),
"parting_precision": float(payload.get("parting_precision", 0.1)),
"cavity_match": int(payload.get("cavity_match", 95)),
}
async def _step_verify( async def _step_verify(
self, file_path: str, db_session: AsyncSession, self, file_path: str, db_session: AsyncSession,
task_id: str, stp_file_id: int, analysis_result: Optional[dict], task_id: str, stp_file_id: int, analysis_result: Optional[dict],
+39 -6
View File
@@ -127,10 +127,14 @@ class StorageIntegrationService:
logger.info(f"STP文件保存成功 RustFS: {stp_file.id}, 批次: {batch_id}") logger.info(f"STP文件保存成功 RustFS: {stp_file.id}, 批次: {batch_id}")
return stp_file return stp_file
async def create_processing_task(self, session: AsyncSession, async def create_processing_task(
task_id: str, self,
stp_file_id: int, session: AsyncSession,
task_type: str = "stp_parsing") -> ProcessingTask: task_id: str,
stp_file_id: int,
task_type: str = "stp_parsing",
parameters: Optional[Dict[str, Any]] = None,
) -> ProcessingTask:
"""创建处理任务记录""" """创建处理任务记录"""
try: try:
task = ProcessingTask( task = ProcessingTask(
@@ -138,7 +142,8 @@ class StorageIntegrationService:
stp_file_id=stp_file_id, stp_file_id=stp_file_id,
task_type=task_type, task_type=task_type,
status="pending", status="pending",
started_time=datetime.now() started_time=datetime.now(),
parameters=parameters or {},
) )
session.add(task) session.add(task)
@@ -189,6 +194,30 @@ class StorageIntegrationService:
logger.error(f"更新任务状态失败: {e}") logger.error(f"更新任务状态失败: {e}")
raise raise
async def update_task_parameters(
self,
session: AsyncSession,
task_id: str,
parameters: Dict[str, Any],
):
"""合并更新任务参数,便于保存阶段耗时等元数据。"""
try:
task = await session.execute(
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
)
task = task.scalar_one_or_none()
if task is None:
return
merged = dict(task.parameters or {})
merged.update(parameters or {})
task.parameters = merged
await session.commit()
except Exception as e:
await session.rollback()
logger.error(f"更新任务参数失败: {e}")
raise
async def update_stp_file_status(self, session: AsyncSession, stp_file_id: int, status: str): async def update_stp_file_status(self, session: AsyncSession, stp_file_id: int, status: str):
"""更新STP文件状态""" """更新STP文件状态"""
try: try:
@@ -616,7 +645,11 @@ class StorageIntegrationService:
'volume_utilization': stp_file.analysis_metrics.volume_utilization, 'volume_utilization': stp_file.analysis_metrics.volume_utilization,
'topology_complexity': stp_file.analysis_metrics.topology_complexity, 'topology_complexity': stp_file.analysis_metrics.topology_complexity,
'wall_uniformity': stp_file.analysis_metrics.wall_uniformity, 'wall_uniformity': stp_file.analysis_metrics.wall_uniformity,
'analysis_summary': stp_file.analysis_metrics.analysis_summary 'analysis_summary': stp_file.analysis_metrics.analysis_summary,
'verification_status': stp_file.analysis_metrics.verification_status,
'verification_volume_diff': stp_file.analysis_metrics.verification_volume_diff,
'verification_area_diff': stp_file.analysis_metrics.verification_area_diff,
'verification_details': stp_file.analysis_metrics.verification_details,
} }
return result return result
+8
View File
@@ -84,8 +84,10 @@ class TaskQueryService:
# 构造与内存任务兼容的任务视图 # 构造与内存任务兼容的任务视图
cam_preferences = {} cam_preferences = {}
task_parameters = {}
if isinstance(processing_task.parameters, dict): if isinstance(processing_task.parameters, dict):
cam_preferences = processing_task.parameters.get("cam_preferences", {}) or {} cam_preferences = processing_task.parameters.get("cam_preferences", {}) or {}
task_parameters = dict(processing_task.parameters)
task_view = { task_view = {
"task_id": processing_task.task_id, "task_id": processing_task.task_id,
@@ -108,6 +110,12 @@ class TaskQueryService:
"plan_result": cavity_json, "plan_result": cavity_json,
"mesh_summary": mesh_summary, "mesh_summary": mesh_summary,
"html_file": html_file_url, "html_file": html_file_url,
"material": task_parameters.get("material"),
"parameters": task_parameters,
"stage_timings": task_parameters.get("stage_timings", {}),
"verification": task_parameters.get("verification")
or file_with_data.get("analysis_metrics", {}).get("verification_details"),
"llm_report": task_parameters.get("llm_report"),
"analysis_result": { "analysis_result": {
"geometry_data": geometry_json, "geometry_data": geometry_json,
"detected_features": features_json, "detected_features": features_json,
+49 -7
View File
@@ -1,24 +1,66 @@
# utils/file_handler.py # utils/file_handler.py
import aiofiles import aiofiles
import hashlib
import re
import uuid
from pathlib import Path from pathlib import Path
from fastapi import UploadFile from fastapi import UploadFile
from typing import Tuple from typing import Tuple, Dict, Any
class FileHandler: class FileHandler:
def __init__(self, upload_dir: str = "uploads"): def __init__(self, upload_dir: str = "uploads", max_file_size: int = 50 * 1024 * 1024):
self.upload_dir = Path(upload_dir) self.upload_dir = Path(upload_dir)
self.upload_dir.mkdir(exist_ok=True) self.upload_dir.mkdir(exist_ok=True)
self.max_file_size = max_file_size
async def save_uploaded_file(self, file: UploadFile) -> Tuple[Path, int]: def _sanitize_filename(self, filename: str) -> str:
"""保存上传的文件""" original = Path(filename or "upload.step").name
file_path = self.upload_dir / file.filename suffix = Path(original).suffix.lower()
stem = Path(original).stem or "upload"
safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._-") or "upload"
if suffix not in {".stp", ".step"}:
suffix = ".step"
return f"{safe_stem}{suffix}"
@staticmethod
def _looks_like_step(content: bytes) -> bool:
if not content:
return False
head = content[:4096].decode("utf-8", errors="ignore").upper()
return (
"ISO-10303-21" in head
or "HEADER;" in head
or "FILE_SCHEMA" in head
or "DATA;" in head
)
async def save_uploaded_file(self, file: UploadFile) -> Tuple[Path, int, Dict[str, Any]]:
"""保存上传的文件并返回安全元数据"""
content = await file.read() content = await file.read()
async with aiofiles.open(file_path, 'wb') as f:
if not content:
raise ValueError("上传文件为空")
if len(content) > self.max_file_size:
raise ValueError(f"上传文件过大,限制 {self.max_file_size // (1024 * 1024)}MB")
if not self._looks_like_step(content):
raise ValueError("文件内容不是有效的 STP/STEP 数据")
safe_name = self._sanitize_filename(file.filename)
unique_name = f"{uuid.uuid4().hex}_{safe_name}"
file_path = self.upload_dir / unique_name
async with aiofiles.open(file_path, "wb") as f:
await f.write(content) await f.write(content)
return file_path, len(content) metadata = {
"original_filename": file.filename,
"safe_original_name": safe_name,
"stored_filename": unique_name,
"sha256": hashlib.sha256(content).hexdigest(),
}
return file_path, len(content), metadata
def cleanup_file(self, file_path: Path): def cleanup_file(self, file_path: Path):
"""清理文件""" """清理文件"""
+148
View File
@@ -604,6 +604,57 @@ body {
margin-bottom: var(--space-8); margin-bottom: var(--space-8);
} }
.moldinsight-intro-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.intro-card {
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-xs);
}
.intro-card-title {
font-size: var(--text-xs);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: var(--space-2);
}
.intro-card-text {
font-size: var(--text-sm);
line-height: 1.7;
color: var(--text-primary);
font-weight: var(--font-medium);
}
.upload-layout {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(320px, 0.9fr);
gap: var(--space-6);
align-items: start;
margin-bottom: var(--space-8);
}
.upload-main-card,
.upload-side-card {
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
padding: var(--space-6);
}
.upload-main-card .section-title,
.upload-side-card .section-title {
margin-bottom: var(--space-4);
}
.upload-section .btn-primary { .upload-section .btn-primary {
min-width: 200px; min-width: 200px;
padding: var(--space-4) var(--space-8); padding: var(--space-4) var(--space-8);
@@ -649,6 +700,10 @@ body {
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
} }
.compact-panel {
margin-top: 0;
}
.panel-header { .panel-header {
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -749,6 +804,58 @@ optgroup {
font-weight: 500; font-weight: 500;
} }
.advanced-params {
margin-top: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--bg-primary);
}
.advanced-params summary {
cursor: pointer;
list-style: none;
padding: var(--space-4);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.advanced-params summary::-webkit-details-marker {
display: none;
}
.advanced-params-body {
padding: 0 var(--space-4) var(--space-4);
}
.upload-submit-btn {
width: 100%;
margin-top: var(--space-4);
}
.history-collapsible {
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
padding: var(--space-5);
}
.history-summary {
cursor: pointer;
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
list-style: none;
}
.history-summary::-webkit-details-marker {
display: none;
}
.history-collapsible[open] .table-container {
margin-top: var(--space-4);
}
.btn-clear { .btn-clear {
background: none; background: none;
border: none; border: none;
@@ -1191,6 +1298,19 @@ optgroup {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.result-card-highlight {
border: 1px solid var(--primary-100);
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--primary-50) 100%);
}
.result-priority-grid {
margin-bottom: var(--space-6);
}
.compact-metrics-grid {
margin-bottom: var(--space-6);
}
.single-scheme-grid { .single-scheme-grid {
grid-template-columns: 1fr !important; grid-template-columns: 1fr !important;
} }
@@ -1276,6 +1396,22 @@ optgroup {
line-height: 1.6; line-height: 1.6;
} }
.diagnostic-panel summary {
cursor: pointer;
list-style: none;
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.diagnostic-panel summary::-webkit-details-marker {
display: none;
}
.diagnostic-panel-body {
margin-top: var(--space-4);
}
/* ================================ /* ================================
设计建议列表 设计建议列表
================================ */ ================================ */
@@ -2636,6 +2772,11 @@ select.form-input {
flex-wrap: wrap; flex-wrap: wrap;
} }
.export-buttons-block {
margin-left: 0;
margin-top: var(--space-4);
}
.export-buttons .btn-sm { .export-buttons .btn-sm {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.25rem 0.6rem; padding: 0.25rem 0.6rem;
@@ -2695,6 +2836,13 @@ select.form-input {
color: var(--text-primary); color: var(--text-primary);
} }
@media (max-width: 960px) {
.moldinsight-intro-grid,
.upload-layout {
grid-template-columns: 1fr;
}
}
/* ================================ /* ================================
历史记录 历史记录
================================ */ ================================ */
+411 -217
View File
@@ -981,8 +981,6 @@ const UsersView = {
const MoldInsightView = { const MoldInsightView = {
setup() { setup() {
const router = useRouter(); const router = useRouter();
const deliveryDate = ref(null);
const expectedDate = ref(null);
const state = reactive({ const state = reactive({
selectedFile: null, selectedFile: null,
selectedMaterial: 'ABS', selectedMaterial: 'ABS',
@@ -1079,6 +1077,10 @@ const MoldInsightView = {
const formData = new FormData(); const formData = new FormData();
formData.append("file", state.selectedFile); formData.append("file", state.selectedFile);
formData.append("material", state.selectedMaterial); formData.append("material", state.selectedMaterial);
formData.append("draft_angle", String(state.moldParams.draftAngle));
formData.append("shrinkage_rate", String(state.moldParams.shrinkageRate));
formData.append("parting_precision", String(state.moldParams.partingPrecision));
formData.append("cavity_match", String(state.moldParams.cavityMatch));
try { try {
const res = await fetch("/api/upload", { const res = await fetch("/api/upload", {
@@ -1154,11 +1156,6 @@ const MoldInsightView = {
loadHistory(); loadHistory();
}); });
const isFoamMaterial = (material) => {
const foamMaterials = ['AlSi10Mg', 'AlSi12', 'Pure Al Foam', 'AlSi7Mg'];
return foamMaterials.includes(material);
};
return { return {
state, state,
handleFileChange, handleFileChange,
@@ -1168,56 +1165,74 @@ const MoldInsightView = {
formatDateTime, formatDateTime,
formatNumber, formatNumber,
toggleFileHistory, toggleFileHistory,
viewResult, viewResult
isFoamMaterial
}; };
}, },
template: ` template: `
<div class="page-container"> <div class="page-container">
<div class="page-header"> <div class="page-header">
<h1>MoldInsight</h1> <h1>注塑模 STP 分析</h1>
<p>STP 模具几何分析</p> <p>上传 STEP/STP 产品件,完成自动分模、工程建议与导出</p>
</div> </div>
<div class="upload-section"> <div class="moldinsight-intro-grid">
<div <div class="intro-card">
:class="['upload-zone', { 'drag-over': state.dragOver }]" <div class="intro-card-title">输入</div>
@dragover.prevent="state.dragOver = true" <div class="intro-card-text">STEP/STP 产品件,面向注塑模主流程</div>
@dragleave.prevent="state.dragOver = false" </div>
@drop="handleDrop" <div class="intro-card">
@click="$refs.fileInput.click()" <div class="intro-card-title">输出</div>
> <div class="intro-card-text">分模方案、DFM 风险、注塑模系统摘要与 CAD 导出</div>
<input </div>
ref="fileInput" <div class="intro-card">
type="file" <div class="intro-card-title">目标</div>
accept=".stp,.step" <div class="intro-card-text">先确认推荐方案,再进入导出与 CAM 准备</div>
@change="handleFileChange" </div>
hidden </div>
/>
<div class="upload-icon">📁</div> <div class="upload-layout">
<div class="upload-text"> <div class="upload-main-card">
<span class="upload-title">点击选择或拖拽文件</span> <h2 class="section-title">1. 上传产品件</h2>
<span class="upload-hint">支持 .stp, .step 格式,最大 100MB</span> <div
:class="['upload-zone', { 'drag-over': state.dragOver }]"
@dragover.prevent="state.dragOver = true"
@dragleave.prevent="state.dragOver = false"
@drop="handleDrop"
@click="$refs.fileInput.click()"
>
<input
ref="fileInput"
type="file"
accept=".stp,.step"
@change="handleFileChange"
hidden
/>
<div class="upload-icon">📁</div>
<div class="upload-text">
<span class="upload-title">点击选择或拖拽 STP/STEP 文件</span>
<span class="upload-hint">支持注塑模产品件分析,最大 100MB</span>
</div>
</div>
<div v-if="state.selectedFile" class="file-info">
<span class="file-name">{{ state.selectedFile.name }}</span>
<span class="file-size">{{ formatFileSize(state.selectedFile.size) }}</span>
<button class="btn-clear" @click="state.selectedFile = null" title="清除文件">×</button>
</div>
<div v-if="state.error" class="error-message">{{ state.error }}</div>
<div v-if="state.polling" class="progress-bar">
<div class="progress-fill" :style="{ width: state.progress + '%' }"></div>
</div> </div>
</div> </div>
<div v-if="state.selectedFile" class="file-info"> <div class="upload-side-card">
<span class="file-name">{{ state.selectedFile.name }}</span> <h2 class="section-title">2. 注塑模参数</h2>
<span class="file-size">{{ formatFileSize(state.selectedFile.size) }}</span> <div class="material-panel compact-panel">
<button class="btn-clear" @click="state.selectedFile = null" title="清除文件">×</button> <div class="form-group">
</div> <label class="form-label">产品材料</label>
<select v-model="state.selectedMaterial" class="form-select">
<!-- 材料选择面板 -->
<div v-if="state.selectedFile" class="material-panel">
<div class="panel-header">
<span class="panel-title">📦 材料与参数设置</span>
</div>
<!-- 材料类型选择 -->
<div class="form-group">
<label class="form-label">材料类型</label>
<select v-model="state.selectedMaterial" class="form-select">
<optgroup label="普通塑料">
<option value="ABS">ABS (1.05 g/cm³)</option> <option value="ABS">ABS (1.05 g/cm³)</option>
<option value="PP">PP (0.90 g/cm³)</option> <option value="PP">PP (0.90 g/cm³)</option>
<option value="PE">PE (0.95 g/cm³)</option> <option value="PE">PE (0.95 g/cm³)</option>
@@ -1226,70 +1241,58 @@ const MoldInsightView = {
<option value="POM">POM (1.41 g/cm³)</option> <option value="POM">POM (1.41 g/cm³)</option>
<option value="PMMA">PMMA (1.18 g/cm³)</option> <option value="PMMA">PMMA (1.18 g/cm³)</option>
<option value="PBT">PBT (1.31 g/cm³)</option> <option value="PBT">PBT (1.31 g/cm³)</option>
</optgroup> </select>
<optgroup label="铝泡沫材料">
<option value="AlSi10Mg">AlSi10Mg (0.45 g/cm³) - 常用铝硅泡沫</option>
<option value="AlSi12">AlSi12 (0.50 g/cm³) - 高强度铝泡沫</option>
<option value="Pure Al Foam">Pure Al Foam (0.35 g/cm³) - 纯铝泡沫</option>
<option value="AlSi7Mg">AlSi7Mg (0.40 g/cm³) - 轻质铝镁泡沫</option>
</optgroup>
</select>
</div>
<!-- 铝泡沫参数(仅在选择泡沫材料时显示) -->
<div v-if="isFoamMaterial(state.selectedMaterial)" class="foam-params">
<div class="param-section">
<span class="param-title">⚙️ 铝泡沫专用参数</span>
<div class="form-row">
<div class="form-group">
<label class="form-label">拔模角 (°)</label>
<input type="range" v-model.number="state.moldParams.draftAngle" min="1" max="10" step="0.5" class="form-range">
<span class="range-value">{{ state.moldParams.draftAngle }}°</span>
</div>
<div class="form-group">
<label class="form-label">收缩率 (%)</label>
<input type="range" v-model.number="state.moldParams.shrinkageRate" min="0.5" max="3.0" step="0.1" class="form-range">
<span class="range-value">{{ state.moldParams.shrinkageRate }}%</span>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">分型精度 (mm)</label>
<input type="range" v-model.number="state.moldParams.partingPrecision" min="0.01" max="1.0" step="0.01" class="form-range">
<span class="range-value">{{ state.moldParams.partingPrecision }} mm</span>
</div>
<div class="form-group">
<label class="form-label">型腔匹配度 (%)</label>
<input type="range" v-model.number="state.moldParams.cavityMatch" min="80" max="100" step="1" class="form-range">
<span class="range-value">{{ state.moldParams.cavityMatch }}%</span>
</div>
</div>
</div> </div>
<details class="advanced-params">
<summary>高级工艺参数</summary>
<div class="advanced-params-body">
<div class="inline-note">默认值适用于多数注塑件;仅在已知工艺约束时再调整。</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">拔模角 (°)</label>
<input type="range" v-model.number="state.moldParams.draftAngle" min="1" max="10" step="0.5" class="form-range">
<span class="range-value">{{ state.moldParams.draftAngle }}°</span>
</div>
<div class="form-group">
<label class="form-label">收缩率 (%)</label>
<input type="range" v-model.number="state.moldParams.shrinkageRate" min="0.5" max="3.0" step="0.1" class="form-range">
<span class="range-value">{{ state.moldParams.shrinkageRate }}%</span>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">分型精度 (mm)</label>
<input type="range" v-model.number="state.moldParams.partingPrecision" min="0.01" max="1.0" step="0.01" class="form-range">
<span class="range-value">{{ state.moldParams.partingPrecision }} mm</span>
</div>
<div class="form-group">
<label class="form-label">型腔匹配度 (%)</label>
<input type="range" v-model.number="state.moldParams.cavityMatch" min="80" max="100" step="1" class="form-range">
<span class="range-value">{{ state.moldParams.cavityMatch }}%</span>
</div>
</div>
</div>
</details>
</div> </div>
</div>
<button
<div v-if="state.error" class="error-message">{{ state.error }}</div> v-if="state.selectedFile"
class="btn-primary upload-submit-btn"
<button @click="uploadFile"
v-if="state.selectedFile" :disabled="state.uploading || state.polling"
class="btn-primary" >
@click="uploadFile" {{ state.uploading ? '上传中...' : state.polling ? '分析中...' : '3. 开始注塑模分析' }}
:disabled="state.uploading || state.polling" </button>
> <div v-else class="inline-note">先选择 STP 文件,再填写材料并开始分析。</div>
{{ state.uploading ? '上传中...' : state.polling ? '分析中...' : '开始分析' }}
</button>
<div v-if="state.polling" class="progress-bar">
<div class="progress-fill" :style="{ width: state.progress + '%' }"></div>
</div> </div>
</div> </div>
<div v-if="state.history?.files?.length" class="section"> <details v-if="state.history?.files?.length" class="history-collapsible section">
<h2 class="section-title">分析历史</h2> <summary class="history-summary">分析历史({{ state.history.files.length }} 个文件)</summary>
<div class="table-container"> <div class="table-container">
<table class="data-table"> <table class="data-table">
<thead> <thead>
@@ -1369,7 +1372,7 @@ const MoldInsightView = {
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </details>
</div> </div>
` `
}; };
@@ -1457,10 +1460,6 @@ const ResultView = {
return priorityMap[priority] || priority; return priorityMap[priority] || priority;
}; };
const isFoamMaterial = (material) => {
return material === 'aluminum_foam';
};
const candidateSchemes = computed(() => state.task?.candidate_schemes || []); const candidateSchemes = computed(() => state.task?.candidate_schemes || []);
const hasSingleScheme = computed(() => candidateSchemes.value.length === 1); const hasSingleScheme = computed(() => candidateSchemes.value.length === 1);
const selectedScheme = computed(() => { const selectedScheme = computed(() => {
@@ -1471,6 +1470,33 @@ const ResultView = {
const selectedKeyInfo = computed(() => selectedScheme.value?.key_info || state.task?.key_info || null); const selectedKeyInfo = computed(() => selectedScheme.value?.key_info || state.task?.key_info || null);
const selectedHtmlFile = computed(() => selectedScheme.value?.html_file || state.task?.html_file || ''); const selectedHtmlFile = computed(() => selectedScheme.value?.html_file || state.task?.html_file || '');
const selectedDfmViolations = computed(() => selectedScheme.value?.dfm_violations || []); const selectedDfmViolations = computed(() => selectedScheme.value?.dfm_violations || []);
const selectedInjectionSystem = computed(() => selectedCavityData.value?.injection_system || state.task?.plan_result?.injection_system || null);
const stageTimingEntries = computed(() => {
const timings = state.task?.stage_timings || {};
return Object.entries(timings)
.filter(([, value]) => typeof value === 'number')
.sort((a, b) => b[1] - a[1]);
});
const sortedRecommendations = computed(() => {
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
return [...(state.task?.analysis_result?.design_recommendations || [])].sort((a, b) => {
return (priorityOrder[a.priority] ?? 99) - (priorityOrder[b.priority] ?? 99);
});
});
const primaryRecommendation = computed(() => sortedRecommendations.value[0] || null);
const selectedDfmCount = computed(() => selectedDfmViolations.value?.length || 0);
const selectedCavityCount = computed(() => {
const cavityValue = selectedCavityData.value?.mold_cavities?.cavity_count;
if (typeof cavityValue === 'number') return cavityValue;
const cavityObj = selectedCavityData.value?.mold_cavities || selectedKeyInfo.value?.mold_cavities || {};
return cavityObj.cavity_count || Object.keys(cavityObj).filter(key => key.startsWith('cavity_')).length || 1;
});
const selectedRiskLabel = computed(() => {
if (!selectedDfmCount.value) return '低风险';
if (selectedDfmCount.value >= 4) return '高风险';
if (selectedDfmCount.value >= 2) return '中风险';
return '低风险';
});
const selectScheme = (schemeId) => { const selectScheme = (schemeId) => {
state.selectedSchemeId = schemeId; state.selectedSchemeId = schemeId;
@@ -1495,6 +1521,40 @@ const ResultView = {
return `[${dir.map(v => Number(v).toFixed(2)).join(', ')}]`; return `[${dir.map(v => Number(v).toFixed(2)).join(', ')}]`;
}; };
const analysisFeatures = computed(() => state.task?.analysis_result?.detected_features || []);
const getFeatureByTypes = (...types) => analysisFeatures.value.find(f => types.includes(f.feature_type));
const countFeaturesByTypes = (...types) => analysisFeatures.value.filter(f => types.includes(f.feature_type)).length;
const getWallThicknessSummary = () => {
const feature = getFeatureByTypes('thin_wall', 'thick_wall', 'wall_non_uniform');
if (!feature) return '待分析';
const params = feature.parameters || {};
if (params.min_thickness != null && params.max_thickness != null) {
return `${Number(params.min_thickness).toFixed(2)} - ${Number(params.max_thickness).toFixed(2)} mm`;
}
if (params.average_thickness != null) {
return `平均 ${Number(params.average_thickness).toFixed(2)} mm`;
}
return feature.recommendations?.[0] || '已完成分析';
};
const getUndercutCount = () => {
const fromScheme = selectedScheme.value?.undercut_regions?.length || selectedCavityData.value?.undercut_regions?.length || 0;
return fromScheme || countFeaturesByTypes('undercut');
};
const formatStageName = (name) => {
const stageNameMap = {
parse_stp: 'STP解析',
generate_mesh: '网格生成',
generate_cavity: '分模与型腔生成',
build_plan_result: '方案结果组装',
persist_artifacts: '结果持久化',
analyze_design: '几何分析',
verify_geometry: '几何验证',
generate_llm_report: 'LLM报告生成'
};
return stageNameMap[name] || name;
};
const formatTiming = (value) => `${Number(value || 0).toFixed(3)} s`;
const exportCAD = async (format) => { const exportCAD = async (format) => {
try { try {
const taskId = route.params.taskId; const taskId = route.params.taskId;
@@ -1503,6 +1563,7 @@ const ResultView = {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
task_id: taskId, task_id: taskId,
scheme_id: selectedScheme.value?.scheme_id || state.selectedSchemeId,
formats: [format], formats: [format],
components: ['cavity', 'core', 'parting_surface'] components: ['cavity', 'core', 'parting_surface']
}) })
@@ -1586,11 +1647,22 @@ const ResultView = {
selectedKeyInfo, selectedKeyInfo,
selectedHtmlFile, selectedHtmlFile,
selectedDfmViolations, selectedDfmViolations,
selectedInjectionSystem,
stageTimingEntries,
getWallThicknessSummary,
countFeaturesByTypes,
getUndercutCount,
formatStageName,
formatTiming,
primaryRecommendation,
sortedRecommendations,
selectedDfmCount,
selectedCavityCount,
selectedRiskLabel,
formatFileSize, formatFileSize,
formatDateTime, formatDateTime,
formatNumber, formatNumber,
getPriorityText, getPriorityText,
isFoamMaterial,
exportCAD, exportCAD,
generateCamPlan, generateCamPlan,
camSteelOptions, camSteelOptions,
@@ -1623,28 +1695,90 @@ const ResultView = {
<span :class="['badge', state.task.status === 'completed' ? 'badge-success' : 'badge-error']"> <span :class="['badge', state.task.status === 'completed' ? 'badge-success' : 'badge-error']">
{{ state.task.status }} {{ state.task.status }}
</span> </span>
<div v-if="state.task.status === 'completed'" class="export-buttons"> </div>
<button class="btn-sm btn-primary" :disabled="state.camLoading" @click="generateCamPlan()" title="基于当前选中方案生成CAM工艺计划">
{{ state.camLoading ? '生成中...' : '生成 CAM 计划' }} <div class="result-grid result-priority-grid">
</button> <div class="result-card result-card-highlight">
<button class="btn-sm btn-primary" @click="exportCAD('step')" title="导出STEP格式(UG/FreeCAD/SolidWorks通用)"> <div class="summary-header">
导出 STEP <h3>推荐方案</h3>
</button> <span class="badge badge-info">{{ selectedScheme?.title || selectedScheme?.scheme_id || '方案待定' }}</span>
<button class="btn-sm btn-secondary" @click="exportCAD('stl')" title="导出STL网格格式(3D打印预览)"> </div>
导出 STL <div class="info-list">
</button> <div class="info-item">
<button class="btn-sm btn-secondary" @click="exportCAD('iges')" title="导出IGES格式(兼容旧系统)"> <span class="info-label">分型方向</span>
导出 IGES <span class="info-value">{{ formatSchemeDirection(selectedScheme) }}</span>
</button> </div>
<button class="btn-sm btn-secondary" @click="exportCAD('brep')" title="导出BRep格式(FreeCAD原生)"> <div class="info-item">
导出 BRep <span class="info-label">分型面位置</span>
</button> <span class="info-value">{{ selectedScheme?.offset_label || '中面' }}</span>
</div>
<div class="info-item">
<span class="info-label">方案总分</span>
<span class="info-value">{{ formatNumber(selectedScheme?.score || 0) }}</span>
</div>
<div class="info-item">
<span class="info-label">方案可信度</span>
<span class="info-value">{{ formatNumber(selectedScheme?.confidence_score || 0) }}</span>
</div>
<div class="info-item">
<span class="info-label">主要理由</span>
<span class="info-value">{{ selectedScheme?.summary || primaryRecommendation?.description || '基于当前几何与制造约束自动推荐' }}</span>
</div>
<div class="info-item">
<span class="info-label">高优先级建议</span>
<span class="info-value">{{ primaryRecommendation?.description || '未发现高优先级工艺建议' }}</span>
</div>
</div>
</div>
<div class="result-card result-card-highlight">
<div class="summary-header">
<h3>关键操作</h3>
<span :class="['badge', selectedRiskLabel === '高风险' ? 'badge-error' : selectedRiskLabel === '中风险' ? 'badge-warning' : 'badge-success']">
{{ selectedRiskLabel }}
</span>
</div>
<div class="info-list">
<div class="info-item">
<span class="info-label">DFM 风险数</span>
<span class="info-value">{{ selectedDfmCount }} 项</span>
</div>
<div class="info-item">
<span class="info-label">推荐型腔数</span>
<span class="info-value">{{ selectedCavityCount }} 腔</span>
</div>
<div class="info-item">
<span class="info-label">预计成型周期</span>
<span class="info-value">{{ selectedInjectionSystem?.overall_assessment?.estimated_cycle_time || 'N/A' }} s</span>
</div>
<div class="info-item">
<span class="info-label">下一步</span>
<span class="info-value">{{ selectedDfmCount ? '先处理 DFM 风险,再确认导出或 CAM' : '可进入 3D 复核、导出与 CAM 准备' }}</span>
</div>
</div>
<div v-if="state.task.status === 'completed'" class="export-buttons export-buttons-block">
<button class="btn-sm btn-primary" @click="exportCAD('step')" title="导出STEP格式(UG/FreeCAD/SolidWorks通用)">
导出 STEP
</button>
<button class="btn-sm btn-secondary" @click="exportCAD('iges')" title="导出IGES格式(兼容旧系统)">
导出 IGES
</button>
<button class="btn-sm btn-secondary" @click="exportCAD('stl')" title="导出STL网格格式(3D打印预览)">
导出 STL
</button>
<button class="btn-sm btn-secondary" @click="exportCAD('brep')" title="导出BRep格式(FreeCAD原生)">
导出 BRep
</button>
<button class="btn-sm btn-primary" :disabled="state.camLoading" @click="generateCamPlan()" title="基于当前选中方案生成CAM工艺计划">
{{ state.camLoading ? '生成中...' : '生成 CAM 计划' }}
</button>
</div>
</div> </div>
</div> </div>
<div class="result-grid"> <div class="result-grid compact-metrics-grid">
<div class="result-card"> <div class="result-card">
<h3>文件信息</h3> <h3>任务信息</h3>
<div class="info-list"> <div class="info-list">
<div class="info-item"> <div class="info-item">
<span class="info-label">文件大小</span> <span class="info-label">文件大小</span>
@@ -1662,7 +1796,7 @@ const ResultView = {
</div> </div>
<div class="result-card"> <div class="result-card">
<h3>几何数据</h3> <h3>几何概览</h3>
<div class="info-list"> <div class="info-list">
<div class="info-item" v-if="state.task.mesh_summary"> <div class="info-item" v-if="state.task.mesh_summary">
<span class="info-label">顶点数</span> <span class="info-label">顶点数</span>
@@ -1690,6 +1824,7 @@ const ResultView = {
<div v-if="candidateSchemes.length" class="viewer-section"> <div v-if="candidateSchemes.length" class="viewer-section">
<h3>候选分模方案</h3> <h3>候选分模方案</h3>
<div class="inline-note">先切换并确认推荐方案,再进入后续工程判断、导出与 CAM。</div>
<div :class="['result-grid', hasSingleScheme ? 'single-scheme-grid' : '']"> <div :class="['result-grid', hasSingleScheme ? 'single-scheme-grid' : '']">
<div <div
v-for="scheme in candidateSchemes" v-for="scheme in candidateSchemes"
@@ -1765,9 +1900,9 @@ const ResultView = {
</div> </div>
</div> </div>
<div v-if="candidateSchemes.length > 1" class="viewer-section"> <details v-if="candidateSchemes.length > 1" class="viewer-section diagnostic-panel">
<h3>方案对比</h3> <summary>方案对比</summary>
<div class="result-card full-width"> <div class="result-card full-width diagnostic-panel-body">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
@@ -1801,13 +1936,13 @@ const ResultView = {
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </details>
<div v-if="selectedCavityData || selectedKeyInfo" class="viewer-section"> <div v-if="selectedCavityData || selectedKeyInfo" class="viewer-section">
<h3>模具信息</h3> <h3>工程摘要</h3>
<div class="result-grid"> <div class="result-grid">
<div class="result-card"> <div class="result-card">
<h4>基本信息</h4> <h4>方案与零件</h4>
<div class="info-list"> <div class="info-list">
<div class="info-item"> <div class="info-item">
<span class="info-label">零件名称</span> <span class="info-label">零件名称</span>
@@ -1824,11 +1959,44 @@ const ResultView = {
</div> </div>
</div> </div>
<div class="result-card" v-if="selectedCavityData?.mold_cavities || selectedKeyInfo?.mold_cavities"> <div class="result-card" v-if="selectedCavityData?.mold_cavities || selectedKeyInfo?.mold_cavities">
<h4>型腔信息</h4> <h4>型腔与锁模</h4>
<div class="info-list"> <div class="info-list">
<div class="info-item"> <div class="info-item">
<span class="info-label">型腔数量</span> <span class="info-label">型腔数量</span>
<span class="info-value">{{ Object.keys(selectedCavityData?.mold_cavities || selectedKeyInfo?.mold_cavities || {}).length }}</span> <span class="info-value">{{ selectedCavityCount }}</span>
</div>
<div class="info-item">
<span class="info-label">预估锁模力</span>
<span class="info-value">{{ selectedCavityData?.manufacturing_info?.estimated_clamping_force || '自动计算' }}</span>
</div>
</div>
</div>
<div class="result-card" v-if="selectedInjectionSystem">
<h4>注塑模系统</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">预计成型周期</span>
<span class="info-value">{{ selectedInjectionSystem?.overall_assessment?.estimated_cycle_time || 'N/A' }} s</span>
</div>
<div class="info-item">
<span class="info-label">冷却时间</span>
<span class="info-value">{{ selectedInjectionSystem?.cooling?.cooling_time || 'N/A' }} s</span>
</div>
<div class="info-item">
<span class="info-label">水路数量</span>
<span class="info-value">{{ selectedInjectionSystem?.cooling?.thermal_check?.channel_count || 0 }}</span>
</div>
<div class="info-item">
<span class="info-label">冷却流量</span>
<span class="info-value">{{ selectedInjectionSystem?.cooling?.flow_rate?.flow_rate_lpm || 'N/A' }} L/min</span>
</div>
<div class="info-item">
<span class="info-label">浇口类型</span>
<span class="info-value">{{ selectedInjectionSystem?.gating?.gate_type || 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">流道形式</span>
<span class="info-value">{{ selectedInjectionSystem?.gating?.runner?.type || 'N/A' }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1861,8 +2029,31 @@ const ResultView = {
</div> </div>
</div> </div>
<div v-if="selectedHtmlFile" class="viewer-section">
<h3>3D 预览</h3>
<div v-if="state.previewStatus === 'error'" class="inline-alert inline-alert-warning" style="margin-bottom: var(--space-3);">
<div class="inline-alert-title">预览加载失败</div>
<div class="inline-alert-message">HTML 已生成但加载异常,请检查该链接是否可访问:{{ selectedHtmlFile }}</div>
</div>
<iframe
:key="selectedHtmlFile"
:src="selectedHtmlFile"
class="viewer-frame"
@load="onPreviewLoad"
@error="onPreviewError"
></iframe>
</div>
<div v-else class="viewer-section">
<h3>3D 预览</h3>
<div class="inline-alert inline-alert-warning">
<div class="inline-alert-title">预览未生成</div>
<div class="inline-alert-message">当前任务没有返回 HTML 预览链接,属于未生成状态。</div>
</div>
</div>
<div class="viewer-section"> <div class="viewer-section">
<h3>CAM 准备包</h3> <h3>导出与 CAM</h3>
<div class="result-card" style="margin-bottom: var(--space-4);"> <div class="result-card" style="margin-bottom: var(--space-4);">
<h4>CAM 参数</h4> <h4>CAM 参数</h4>
<div class="info-list"> <div class="info-list">
@@ -1973,62 +2164,9 @@ const ResultView = {
</div> </div>
</template> </template>
</div> </div>
<div v-if="selectedHtmlFile" class="viewer-section">
<h3>3D 预览</h3>
<div v-if="state.previewStatus === 'error'" class="inline-alert inline-alert-warning" style="margin-bottom: var(--space-3);">
<div class="inline-alert-title">预览加载失败</div>
<div class="inline-alert-message">HTML 已生成但加载异常,请检查该链接是否可访问:{{ selectedHtmlFile }}</div>
</div>
<iframe
:key="selectedHtmlFile"
:src="selectedHtmlFile"
class="viewer-frame"
@load="onPreviewLoad"
@error="onPreviewError"
></iframe>
</div>
<div v-else class="viewer-section">
<h3>3D 预览</h3>
<div class="inline-alert inline-alert-warning">
<div class="inline-alert-title">预览未生成</div>
<div class="inline-alert-message">当前任务没有返回 HTML 预览链接,属于未生成状态。</div>
</div>
</div>
<div v-if="state.task.analysis_result" class="viewer-section"> <div v-if="state.task.analysis_result" class="viewer-section">
<h3>分析结果详情</h3> <h3>分析结果详情</h3>
<!-- FreeCAD 验证结果 -->
<div v-if="state.task.verification" class="verification-section">
<div class="verification-summary">
<div class="summary-header">
<h4>FreeCAD 几何验证</h4>
<span :class="['badge', state.task.verification.status === 'passed' ? 'badge-success' : state.task.verification.status === 'failed' ? 'badge-error' : state.task.verification.status === 'error' ? 'badge-warning' : 'badge-info']">
{{ state.task.verification.status === 'passed' ? '验证通过' : state.task.verification.status === 'failed' ? '验证失败' : state.task.verification.status === 'error' ? '验证错误' : state.task.verification.status === 'skipped' ? '已跳过' : '未知' }}
</span>
</div>
<div class="verification-details" v-if="state.task.verification.comparison && (state.task.verification.comparison.volume || state.task.verification.comparison.surface_area)">
<div class="comparison-grid">
<div class="comparison-item" v-if="state.task.verification.comparison.volume">
<span class="comparison-label">体积差异</span>
<span class="comparison-value" :class="{'text-error': state.task.verification.comparison.volume.difference_percent > 1, 'text-success': state.task.verification.comparison.volume.difference_percent <= 1}">
{{ state.task.verification.comparison.volume.difference_percent?.toFixed(4) || 0 }}%
</span>
</div>
<div class="comparison-item" v-if="state.task.verification.comparison.surface_area">
<span class="comparison-label">表面积差异</span>
<span class="comparison-value" :class="{'text-error': state.task.verification.comparison.surface_area.difference_percent > 2, 'text-success': state.task.verification.comparison.surface_area.difference_percent <= 2}">
{{ state.task.verification.comparison.surface_area.difference_percent?.toFixed(4) || 0 }}%
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 分析摘要 -->
<div class="analysis-summary" v-if="state.task.analysis_result.analysis_summary"> <div class="analysis-summary" v-if="state.task.analysis_result.analysis_summary">
<div class="summary-header"> <div class="summary-header">
<h4>分析摘要</h4> <h4>分析摘要</h4>
@@ -2038,7 +2176,7 @@ const ResultView = {
{{ state.task.analysis_result.analysis_summary }} {{ state.task.analysis_result.analysis_summary }}
</div> </div>
</div> </div>
<div class="result-grid"> <div class="result-grid">
<div class="result-card full-width"> <div class="result-card full-width">
<h4>1. 产品特征识别</h4> <h4>1. 产品特征识别</h4>
@@ -2052,19 +2190,19 @@ const ResultView = {
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">壁厚分布</span> <span class="info-label">壁厚分布</span>
<span class="info-value">{{ state.task.analysis_result.detected_features?.find(f => f.feature_type === 'wall_thickness')?.description || '待分析' }}</span> <span class="info-value">{{ getWallThicknessSummary() }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">加强筋位置和密度</span> <span class="info-label">加强筋位置和密度</span>
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === 'rib').length || 0 }} 个加强筋</span> <span class="info-value">{{ countFeaturesByTypes('rib_structure') || 0 }} 个加强筋</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">孔洞和凹槽位置</span> <span class="info-label">柱位和功能特征</span>
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === 'hole' || f.feature_type === 'pocket').length || 0 }} 个孔洞/凹槽</span> <span class="info-value">{{ countFeaturesByTypes('boss_feature') || 0 }} 个柱位/功能特征</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">倒扣区域检测</span> <span class="info-label">倒扣区域检测</span>
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === 'undercut').length || 0 }} 个倒扣区域</span> <span class="info-value">{{ getUndercutCount() }} 个倒扣区域</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">对称性分析</span> <span class="info-label">对称性分析</span>
@@ -2081,27 +2219,27 @@ const ResultView = {
</div> </div>
<div class="result-card full-width"> <div class="result-card full-width">
<h4>2. 泡沫包装设计决策</h4> <h4>2. 注塑模设计决策</h4>
<div class="info-list"> <div class="info-list">
<div class="info-item"> <div class="info-item">
<span class="info-label">泡沫厚度建议</span> <span class="info-label">高优先级工艺建议</span>
<span class="info-value">{{ state.task.analysis_result.design_recommendations?.find(r => r.priority === 'high')?.recommendation || '根据产品重量和脆弱程度自动计算' }}</span> <span class="info-value">{{ state.task.analysis_result.design_recommendations?.find(r => r.priority === 'high')?.description || '根据几何分析自动生成' }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">加强筋布局</span> <span class="info-label">加强筋布局</span>
<span class="info-value">基于产品薄弱区域自动布置</span> <span class="info-value">{{ countFeaturesByTypes('rib_structure') ? '已识别加强筋区域,建议校核厚度和脱模方向' : '当前未识别明显加强筋特征' }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">取手槽位置</span> <span class="info-label">浇口与进胶提示</span>
<span class="info-value">基于重心位置:{{ state.task.analysis_result.geometry_data?.center_of_mass ? '自动优化' : '手动设置' }}</span> <span class="info-value">{{ state.task.analysis_result.design_recommendations?.find(r => r.type === 'wall_thickness' || r.type === 'draft_angle')?.reason || '建议结合主分型方向确认浇口位置' }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">通风孔位置</span> <span class="info-label">冷却设计提示</span>
<span class="info-value">防止真空吸附:建议在产品最大平面区域设置通风孔</span> <span class="info-value">{{ getWallThicknessSummary() !== '待分析' ? '建议按壁厚分布校核冷却均匀性' : '需先完成壁厚分析后再校核冷却' }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">定位结构设计</span> <span class="info-label">定位结构设计</span>
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === '定位').length || 0 }} 个定位特征</span> <span class="info-value">{{ countFeaturesByTypes('boss_feature') || 0 }} 个柱位/定位相关特征</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">分型面选择</span> <span class="info-label">分型面选择</span>
@@ -2109,13 +2247,10 @@ const ResultView = {
</div> </div>
<!-- 设计建议列表 --> <!-- 设计建议列表 -->
<div class="recommendations-section" v-if="state.task.analysis_result.design_recommendations?.length"> <div class="recommendations-section" v-if="sortedRecommendations.length">
<h5>详细设计建议</h5> <h5>详细设计建议</h5>
<div class="recommendations-list"> <div class="recommendations-list">
<div v-for="rec in state.task.analysis_result.design_recommendations.sort((a, b) => { <div v-for="rec in sortedRecommendations" :key="rec.type" class="recommendation-item" :class="rec.priority">
const priorityOrder = { 'critical': 0, 'high': 1, 'medium': 2, 'low': 3 };
return priorityOrder[a.priority] - priorityOrder[b.priority];
})" :key="rec.type" class="recommendation-item" :class="rec.priority">
<span class="priority-badge" :class="rec.priority">{{ getPriorityText(rec.priority) }}</span> <span class="priority-badge" :class="rec.priority">{{ getPriorityText(rec.priority) }}</span>
<span class="recommendation-text">{{ rec.description }}</span> <span class="recommendation-text">{{ rec.description }}</span>
<span class="recommendation-reason" v-if="rec.reason">({{ rec.reason }})</span> <span class="recommendation-reason" v-if="rec.reason">({{ rec.reason }})</span>
@@ -2179,6 +2314,65 @@ const ResultView = {
</div> </div>
</div> </div>
</div> </div>
<details class="viewer-section diagnostic-panel" v-if="stageTimingEntries.length || state.task.verification || state.task.llm_report">
<summary>诊断信息</summary>
<div class="diagnostic-panel-body">
<div class="result-card full-width" v-if="stageTimingEntries.length">
<h4>处理耗时</h4>
<table class="data-table">
<thead>
<tr>
<th>阶段</th>
<th>耗时</th>
</tr>
</thead>
<tbody>
<tr v-for="[stage, duration] in stageTimingEntries" :key="'timing-' + stage">
<td>{{ formatStageName(stage) }}</td>
<td>{{ formatTiming(duration) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="state.task.verification" class="verification-section">
<div class="verification-summary">
<div class="summary-header">
<h4>FreeCAD 几何验证</h4>
<span :class="['badge', state.task.verification.status === 'passed' ? 'badge-success' : state.task.verification.status === 'failed' ? 'badge-error' : state.task.verification.status === 'error' ? 'badge-warning' : 'badge-info']">
{{ state.task.verification.status === 'passed' ? '验证通过' : state.task.verification.status === 'failed' ? '验证失败' : state.task.verification.status === 'error' ? '验证错误' : state.task.verification.status === 'skipped' ? '已跳过' : '未知' }}
</span>
</div>
<div class="verification-details" v-if="state.task.verification.comparison && (state.task.verification.comparison.volume || state.task.verification.comparison.surface_area)">
<div class="comparison-grid">
<div class="comparison-item" v-if="state.task.verification.comparison.volume">
<span class="comparison-label">体积差异</span>
<span class="comparison-value" :class="{'text-error': state.task.verification.comparison.volume.difference_percent > 1, 'text-success': state.task.verification.comparison.volume.difference_percent <= 1}">
{{ state.task.verification.comparison.volume.difference_percent?.toFixed(4) || 0 }}%
</span>
</div>
<div class="comparison-item" v-if="state.task.verification.comparison.surface_area">
<span class="comparison-label">表面积差异</span>
<span class="comparison-value" :class="{'text-error': state.task.verification.comparison.surface_area.difference_percent > 2, 'text-success': state.task.verification.comparison.surface_area.difference_percent <= 2}">
{{ state.task.verification.comparison.surface_area.difference_percent?.toFixed(4) || 0 }}%
</span>
</div>
</div>
</div>
</div>
</div>
<div class="analysis-summary" v-if="state.task.llm_report">
<div class="summary-header">
<h4>LLM 设计报告</h4>
</div>
<div class="summary-content">
<pre style="white-space: pre-wrap; margin: 0;">{{ state.task.llm_report }}</pre>
</div>
</div>
</div>
</details>
</div> </div>
</div> </div>
` `
+35
View File
@@ -0,0 +1,35 @@
pythonocc-core>=7.7.0
trimesh>=3.21.0
numpy>=1.24.0
scipy>=1.10.0
pyvista>=0.38.0
fastapi>=0.100.0
uvicorn[standard]>=0.22.0
pydantic>=2.0.0
python-multipart>=0.0.6
sqlalchemy>=2.0.0
psycopg2-binary>=2.9.0
asyncpg>=0.28.0
alembic>=1.11.0
minio>=7.1.0
aiohttp>=3.8.0
kafka-python>=2.0.2
redis>=4.5.0
python-jose[cryptography]>=3.3.0
bcrypt>=4.0.0
passlib>=1.7.4
email-validator>=2.0.0
aiofiles>=23.0.0
orjson>=3.9.0
python-dotenv>=1.0.0
jinja2>=3.1.0
pyyaml>=6.0
python-dateutil>=2.8.0
loguru>=0.7.0
pytest>=7.0.0
pytest-asyncio>=0.21.0
httpx>=0.24.0
aiosqlite>=0.19.0
black>=23.0.0
flake8>=6.0.0
mypy>=1.0.0
+265
View File
@@ -0,0 +1,265 @@
import io
import importlib.util
import sys
import types
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI, UploadFile
from httpx import ASGITransport, AsyncClient
from services.calculation_service import CalculationService
from utils.file_handler import FileHandler
VALID_STEP_BYTES = (
b"ISO-10303-21;\n"
b"HEADER;\n"
b"FILE_DESCRIPTION(('STEP AP214'),'1');\n"
b"ENDSEC;\n"
b"DATA;\n"
b"ENDSEC;\n"
b"END-ISO-10303-21;\n"
)
def _load_module_from_path(module_name: str, file_path: str, stub_modules: dict[str, object]):
originals = {}
for name, module in stub_modules.items():
originals[name] = sys.modules.get(name)
sys.modules[name] = module
try:
spec = importlib.util.spec_from_file_location(module_name, file_path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
finally:
for name, original in originals.items():
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
@pytest.mark.asyncio
async def test_file_handler_sanitizes_step_filename(tmp_path):
handler = FileHandler(upload_dir=str(tmp_path))
upload = UploadFile(filename="../../bad name?.step", file=io.BytesIO(VALID_STEP_BYTES))
file_path, file_size, meta = await handler.save_uploaded_file(upload)
assert file_path.exists()
assert file_size == len(VALID_STEP_BYTES)
assert file_path.parent == tmp_path
assert ".." not in file_path.name
assert meta["safe_original_name"] == "bad_name.step"
assert len(meta["sha256"]) == 64
@pytest.mark.asyncio
async def test_file_handler_rejects_invalid_step_content(tmp_path):
handler = FileHandler(upload_dir=str(tmp_path))
upload = UploadFile(filename="fake.step", file=io.BytesIO(b"not-a-step"))
with pytest.raises(ValueError, match="不是有效的 STP/STEP 数据"):
await handler.save_uploaded_file(upload)
@pytest.mark.asyncio
async def test_export_route_requires_cached_shapes(monkeypatch):
fake_processing_service = types.ModuleType("services.processing_service")
fake_processing_service.processing_service = SimpleNamespace(
get_export_shapes=lambda task_id, scheme_id=None: None,
)
fake_auth_service = types.ModuleType("services.auth_service")
async def fake_current_user():
return SimpleNamespace(id=1)
fake_auth_service.get_current_active_user = fake_current_user
fake_redis_task_manager = types.ModuleType("services.redis_task_manager")
fake_redis_task_manager.redis_task_manager = SimpleNamespace(get_task=None)
fake_models_database = types.ModuleType("models.database")
fake_models_database.User = SimpleNamespace
advanced_router = _load_module_from_path(
"temp_advanced_router",
"d:\\Project\\geMoldInsight\\src\\api\\v1\\advanced_router.py",
{
"services.processing_service": fake_processing_service,
"services.auth_service": fake_auth_service,
"services.redis_task_manager": fake_redis_task_manager,
"models.database": fake_models_database,
},
)
app = FastAPI()
app.include_router(advanced_router.router)
app.dependency_overrides[advanced_router.get_current_active_user] = lambda: SimpleNamespace(id=1)
async def fake_get_task_data(task_id):
return {
"task_id": task_id,
"filename": "demo.step",
"best_scheme_id": "scheme_1",
}
monkeypatch.setattr(advanced_router, "_get_task_data", fake_get_task_data)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/export-mold",
json={"task_id": "task-1", "scheme_id": "scheme_1", "formats": ["step"]},
)
assert response.status_code == 409
assert "导出缓存已失效" in response.json()["detail"]
def test_calculation_service_attaches_injection_system_summary():
plan_result = {
"best_scheme_id": "scheme_1",
"candidate_schemes": [
{
"scheme_id": "scheme_1",
"cavity_data": {
"product_analysis": {
"bounding_box": {"dimensions": [100, 80, 30]}
},
"manufacturing_info": {
"estimated_mold_size": {"length": 200, "width": 180, "height": 110}
},
"mold_cavities": {"cavity_count": 1},
},
"key_info": {},
}
],
}
result = CalculationService.attach_injection_system_summaries(plan_result, "ABS")
best_scheme = result["candidate_schemes"][0]
assert "injection_system" in best_scheme["cavity_data"]
assert best_scheme["cavity_data"]["manufacturing_info"]["cooling_summary"]["channel_count"] >= 1
assert best_scheme["cavity_data"]["manufacturing_info"]["gating_summary"]["gate_type"] in {"auto", "side", "center", "submarine", "fan"}
assert "injection_system" in result
@pytest.mark.asyncio
async def test_upload_route_persists_process_parameters(monkeypatch, tmp_path):
captured = {}
class DummyStorageService:
async def save_stp_file(self, session, file_path, original_filename, user_id):
captured["saved_file"] = {
"file_path": str(file_path),
"original_filename": original_filename,
"user_id": user_id,
}
return SimpleNamespace(id=42)
async def create_processing_task(self, session, task_id, stp_file_id, task_type="stp_parsing", parameters=None):
captured["task"] = {
"task_id": task_id,
"stp_file_id": stp_file_id,
"task_type": task_type,
"parameters": parameters,
}
async def fake_save_uploaded_file(file):
target = Path(tmp_path) / "cached_demo.step"
target.write_bytes(VALID_STEP_BYTES)
return target, len(VALID_STEP_BYTES), {
"safe_original_name": "demo.step",
"sha256": "a" * 64,
"original_filename": "demo.step",
"stored_filename": target.name,
}
async def fake_set_task(task_id, task_info):
captured["redis"] = {"task_id": task_id, "task_info": task_info}
async def fake_process_file_with_storage(task_id, file_path, stp_file_id, process_params):
captured["background"] = {
"task_id": task_id,
"file_path": str(file_path),
"stp_file_id": stp_file_id,
"process_params": process_params,
}
fake_processing_service_module = types.ModuleType("services.processing_service")
fake_processing_service_module.processing_service = SimpleNamespace(
process_file_with_storage=fake_process_file_with_storage,
)
fake_storage_module = types.ModuleType("services.storage_integration_rustfs")
fake_storage_module.StorageIntegrationService = lambda: DummyStorageService()
fake_redis_task_manager = types.ModuleType("services.redis_task_manager")
fake_redis_task_manager.redis_task_manager = SimpleNamespace(
set_task=fake_set_task,
)
fake_database_module = types.ModuleType("database.database")
async def override_get_db_session():
yield object()
fake_database_module.get_db_session = override_get_db_session
fake_auth_service = types.ModuleType("services.auth_service")
async def override_get_current_user():
return SimpleNamespace(id=7, username="tester")
fake_auth_service.get_current_active_user = override_get_current_user
fake_models_database = types.ModuleType("models.database")
fake_models_database.User = SimpleNamespace
upload_router = _load_module_from_path(
"temp_upload_router",
"d:\\Project\\geMoldInsight\\src\\api\\v1\\upload_router.py",
{
"services.processing_service": fake_processing_service_module,
"services.storage_integration_rustfs": fake_storage_module,
"services.redis_task_manager": fake_redis_task_manager,
"database.database": fake_database_module,
"services.auth_service": fake_auth_service,
"models.database": fake_models_database,
},
)
monkeypatch.setattr(upload_router.file_handler, "save_uploaded_file", fake_save_uploaded_file)
app = FastAPI()
app.include_router(upload_router.router)
app.dependency_overrides[upload_router.get_db_session] = override_get_db_session
app.dependency_overrides[upload_router.get_current_active_user] = override_get_current_user
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/upload",
files={"file": ("demo.step", VALID_STEP_BYTES, "application/step")},
data={
"material": "ABS",
"draft_angle": "3.5",
"shrinkage_rate": "0.8",
"parting_precision": "0.05",
"cavity_match": "96",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["parameters"] == {
"material": "ABS",
"draft_angle": 3.5,
"shrinkage_rate": 0.8,
"parting_precision": 0.05,
"cavity_match": 96,
}
assert captured["task"]["parameters"] == payload["parameters"]
assert captured["redis"]["task_info"]["parameters"] == payload["parameters"]
assert captured["background"]["process_params"] == payload["parameters"]