477 lines
16 KiB
Vue
477 lines
16 KiB
Vue
<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>
|