Files
geMoldInsight/frontend/src/modules/moldinsight/MoldInsightView.vue
T
2026-05-29 17:30:26 +08:00

477 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-container">
<div class="page-header">
<h1>注塑模 STP 分析</h1>
<p>上传 STEP/STP 产品件,完成自动分模、工程建议与导出</p>
</div>
<div class="moldinsight-intro-grid">
<div class="intro-card">
<div class="intro-card-title">输入</div>
<div class="intro-card-text">STEP/STP 产品件,面向注塑模主流程</div>
</div>
<div class="intro-card">
<div class="intro-card-title">输出</div>
<div class="intro-card-text">分模方案、DFM 风险、注塑模系统摘要与 CAD 导出</div>
</div>
<div class="intro-card">
<div class="intro-card-title">目标</div>
<div class="intro-card-text">先确认推荐方案,再进入导出与 CAM 准备</div>
</div>
</div>
<div class="upload-layout">
<div class="upload-main-card">
<h2 class="section-title">1. 上传产品件</h2>
<div
:class="['upload-zone', { 'drag-over': state.dragOver }]"
@dragover.prevent="state.dragOver = true"
@dragleave.prevent="state.dragOver = false"
@drop="handleDrop"
@click="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 class="upload-side-card">
<h2 class="section-title">2. 注塑模参数</h2>
<div class="material-panel compact-panel">
<div class="form-group">
<label class="form-label">产品材料</label>
<select v-model="state.selectedMaterial" class="form-select">
<option value="ABS">ABS (1.05 g/cm³)</option>
<option value="PP">PP (0.90 g/cm³)</option>
<option value="PE">PE (0.95 g/cm³)</option>
<option value="PC">PC (1.20 g/cm³)</option>
<option value="PA">PA (1.14 g/cm³)</option>
<option value="POM">POM (1.41 g/cm³)</option>
<option value="PMMA">PMMA (1.18 g/cm³)</option>
<option value="PBT">PBT (1.31 g/cm³)</option>
</select>
</div>
<details class="advanced-params">
<summary>高级工艺参数</summary>
<div class="advanced-params-body">
<div class="inline-note">默认值适用于多数注塑件;仅在已知工艺约束时再调整。</div>
<div class="advanced-param-grid">
<div class="param-input-card" v-for="field in moldParamFields" :key="field.key">
<label class="form-label">{{ field.label }}</label>
<div class="param-input-row">
<input
type="number"
class="form-input param-number-input"
v-model.number="state.moldParams[field.key as keyof typeof state.moldParams]"
:min="field.min"
:max="field.max"
:step="field.step"
/>
<span class="param-unit">{{ field.unit }}</span>
</div>
<div class="param-meta-row">
<span class="range-value">默认 {{ field.defaultValue }}{{ field.unit }}</span>
<span class="range-value">范围 {{ field.min }} - {{ field.max }}{{ field.unit }}</span>
</div>
<div class="param-hint">{{ field.hint }}</div>
</div>
</div>
</div>
</details>
</div>
<button
v-if="state.selectedFile"
class="btn-primary upload-submit-btn"
@click="uploadFile"
:disabled="state.uploading || state.polling"
>
{{ state.uploading ? '上传中...' : state.polling ? '分析中...' : '3. 开始注塑模分析' }}
</button>
<div v-else class="inline-note">先选择 STP 文件,再填写材料并开始分析。</div>
</div>
</div>
<details v-if="state.history?.files?.length" class="history-collapsible section">
<summary class="history-summary">分析历史({{ state.history.files.length }} 个文件)</summary>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>文件名</th>
<th>上传次数</th>
<th>文件大小</th>
<th>最新状态</th>
<th>最新分析时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<template v-for="file in state.history.files" :key="file.filename">
<tr>
<td>{{ file.filename }}</td>
<td>
<span class="badge badge-info">{{ file.upload_count }} 次</span>
</td>
<td>{{ formatFileSize(file.file_size) }}</td>
<td>
<span :class="['badge', file.latest_status === 'completed' ? 'badge-success' : file.latest_status === 'failed' ? 'badge-error' : 'badge-warning']">
{{ file.latest_status }}
</span>
</td>
<td>{{ formatDateTime(file.latest_upload_time) }}</td>
<td>
<div class="action-buttons">
<button v-if="file.latest_status === 'completed'" class="btn-sm btn-primary" @click="viewResult({ task_id: file.latest_task_id })">
查看最新
</button>
<button class="btn-sm btn-icon" @click="toggleFileHistory(file.filename)" :title="state.expandedFiles[file.filename] ? '收起' : '展开历史记录'">
<span class="dropdown-icon" :class="{ expanded: state.expandedFiles[file.filename] }">▼</span>
</button>
</div>
</td>
</tr>
<tr v-if="state.expandedFiles[file.filename]" class="history-detail-row">
<td colspan="6">
<div class="history-dropdown">
<table class="data-table inner-table">
<thead>
<tr>
<th>上传时间</th>
<th>文件大小</th>
<th>状态</th>
<th>体积 (mm³)</th>
<th>表面积 (mm²)</th>
<th>重量 (g)</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="record in state.expandedFiles[file.filename]" :key="record.id">
<td>{{ formatDateTime(record.upload_time) }}</td>
<td>{{ formatFileSize(record.file_size) }}</td>
<td>
<span :class="['badge', record.status === 'completed' ? 'badge-success' : record.status === 'failed' ? 'badge-error' : 'badge-warning']">
{{ record.status }}
</span>
</td>
<td>{{ record.volume ? formatNumber(record.volume) : '-' }}</td>
<td>{{ record.surface_area ? formatNumber(record.surface_area) : '-' }}</td>
<td>{{ record.product_weight ? record.product_weight.toFixed(2) : '-' }}</td>
<td>
<button v-if="record.has_analysis" class="btn-sm btn-primary" @click="viewResult(record)">
查看详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</details>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { apiRequest } from '@/shared/api'
import { handleApiError, addNotification } from '@/shared/notification'
import { clearAuth } from '@/shared/auth'
import { formatFileSize, formatDateTime, formatNumber } from '@/shared/utils'
interface HistoryRecord {
id: number
upload_time: string
file_size: number
status: string
volume?: number
surface_area?: number
product_weight?: number
has_analysis?: boolean
task_id?: string
}
interface HistoryFile {
filename: string
upload_count: number
file_size: number
latest_status: string
latest_upload_time: string
latest_task_id?: string
}
interface HistoryData {
files: HistoryFile[]
}
interface MoldParams {
draftAngle: number
shrinkageRate: number
partingPrecision: number
cavityMatch: number
}
interface TaskInfo {
task_id: string
status: string
filename?: string
error?: string
}
const router = useRouter()
const appStore = useAppStore()
const fileInput = ref<HTMLInputElement | null>(null)
const state = reactive({
selectedFile: null as File | null,
selectedMaterial: 'ABS',
moldParams: {
draftAngle: 2.0,
shrinkageRate: 0.5,
partingPrecision: 0.1,
cavityMatch: 95,
} as MoldParams,
uploading: false,
error: '',
currentTask: null as TaskInfo | null,
task: null as TaskInfo | null,
polling: false,
dragOver: false,
progress: 0,
history: null as HistoryData | null,
expandedFiles: {} as Record<string, HistoryRecord[] | null>,
})
const loadHistory = async () => {
try {
state.history = await apiRequest<HistoryData>('/api/history')
} catch (e) {
console.error('加载历史记录失败:', e)
}
}
const toggleFileHistory = async (filename: string) => {
if (state.expandedFiles[filename]) {
state.expandedFiles[filename] = null
} else {
try {
const records = await apiRequest<HistoryRecord[]>(`/api/history/${encodeURIComponent(filename)}`)
state.expandedFiles[filename] = records
} catch (e) {
handleApiError(e, '加载文件历史')
}
}
}
const viewResult = (record: { task_id?: string }) => {
router.push(`/moldinsight/result/${record.task_id}`)
}
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
validateAndSelectFile(file)
}
const validateAndSelectFile = (file: File) => {
const lowerName = file.name.toLowerCase()
if (!lowerName.endsWith('.stp') && !lowerName.endsWith('.step')) {
state.error = '请选择 STP 或 STEP 格式文件'
state.selectedFile = null
return
}
if (file.size > 100 * 1024 * 1024) {
state.error = '文件大小不能超过 100MB'
state.selectedFile = null
return
}
state.error = ''
state.selectedFile = file
addNotification(`已选择文件: ${file.name}`, 'success')
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
state.dragOver = false
const files = event.dataTransfer?.files
if (files && files.length > 0) validateAndSelectFile(files[0])
}
const uploadFile = async () => {
if (!state.selectedFile) return
if (!appStore.token) {
state.error = '请先登录后再上传文件'
addNotification('请先登录', 'warning')
router.push('/login')
return
}
if (appStore.token === 'demo') {
state.error = '演示模式不支持文件上传,请使用完整账户登录'
addNotification('演示模式不支持上传', 'warning')
return
}
state.uploading = true
state.error = ''
state.progress = 0
const formData = new FormData()
formData.append('file', state.selectedFile)
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 {
const res = await fetch('/api/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${appStore.token}` },
body: formData,
})
if (res.status === 401) {
clearAuth()
state.error = '登录已过期,请重新登录'
addNotification('登录已过期,请重新登录', 'warning')
router.push('/login')
return
}
if (!res.ok) throw new Error(`上传失败: ${res.status}`)
const data = await res.json()
state.currentTask = { task_id: data.task_id, status: 'processing', filename: data.file_info?.filename }
addNotification('文件上传成功,开始分析...', 'success')
startPolling(data.task_id)
} catch (e) {
state.error = handleApiError(e, '文件上传')
} finally {
state.uploading = false
}
}
const startPolling = async (taskId: string) => {
console.log('[Polling] 开始轮询任务:', taskId)
state.polling = true
state.progress = 10
let pollCount = 0
const poll = async () => {
try {
pollCount++
state.progress = Math.min(90, 10 + pollCount * 0.5)
const task = await apiRequest<TaskInfo>(`/api/status/${taskId}`, { method: 'POST' })
state.currentTask = task
state.task = task
if (task.status === 'completed') {
state.polling = false
state.progress = 100
addNotification('分析完成', 'success')
loadHistory()
router.push(`/moldinsight/result/${taskId}`)
return
}
if (task.status === 'failed') {
state.polling = false
state.error = task.error || '分析失败'
addNotification('分析失败', 'error')
return
}
if (pollCount < 300) setTimeout(poll, 2000)
} catch (e) {
state.polling = false
state.error = handleApiError(e, '轮询状态')
}
}
poll()
}
const moldParamFields = [
{
key: 'draftAngle',
label: '拔模角',
unit: '°',
min: 1,
max: 10,
step: 0.5,
defaultValue: 2.0,
hint: '常规注塑件建议从 1.5° 到 3° 起步',
},
{
key: 'shrinkageRate',
label: '收缩率',
unit: '%',
min: 0.5,
max: 3.0,
step: 0.1,
defaultValue: 0.5,
hint: '按材料牌号校核,默认值用于首轮方案评估',
},
{
key: 'partingPrecision',
label: '分型精度',
unit: 'mm',
min: 0.01,
max: 1.0,
step: 0.01,
defaultValue: 0.1,
hint: '用于控制分型面拟合与边界容差',
},
{
key: 'cavityMatch',
label: '型腔匹配度',
unit: '%',
min: 80,
max: 100,
step: 1,
defaultValue: 95,
hint: '数值越高越偏向紧配合与严格封合',
},
]
onMounted(() => {
if (!appStore.user) {
router.push('/login')
return
}
loadHistory()
})
</script>