Files
geMoldInsight/static/vue-app.js
T
2026-02-17 00:42:55 +08:00

869 lines
28 KiB
JavaScript
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.
/**
* STP模具几何分析中心 - Vue3单页应用
* 优化版本:统一前端实现,增强用户体验
*/
const { createApp, ref, computed, onMounted, reactive } = Vue;
const { createRouter, createWebHistory, useRoute, useRouter } = VueRouter;
// 全局状态管理
const appState = reactive({
health: null,
loading: false,
notifications: []
});
// 通用工具函数
function formatFileSize(bytes) {
if (!bytes || bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function formatNumber(num) {
if (num === null || num === undefined) return "N/A";
if (num >= 1_000_000) return (num / 1_000_000).toFixed(2) + "M";
if (num >= 1_000) return (num / 1_000).toFixed(2) + "K";
return num.toFixed ? num.toFixed(2) : String(num);
}
function formatDateTime(dateString) {
if (!dateString) return "N/A";
try {
return new Date(dateString).toLocaleString('zh-CN');
} catch {
return dateString;
}
}
function statusText(status) {
const map = {
pending: "排队中",
processing: "处理中",
completed: "已完成",
failed: "失败",
};
return map[status] || status || "未知";
}
// 状态样式映射
function getStatusClass(status) {
const classMap = {
pending: "status-pending",
processing: "status-processing",
completed: "status-completed",
failed: "status-failed"
};
return classMap[status] || "status-pending";
}
// 添加通知
function addNotification(message, type = 'info') {
const notification = {
id: Date.now(),
message,
type,
timestamp: new Date()
};
appState.notifications.push(notification);
// 自动移除通知
setTimeout(() => {
const index = appState.notifications.findIndex(n => n.id === notification.id);
if (index > -1) {
appState.notifications.splice(index, 1);
}
}, 5000);
}
// 错误处理
function handleApiError(error, context = '') {
console.error(`API错误 [${context}]:`, error);
const message = error.message || '请求失败,请稍后重试';
addNotification(message, 'error');
return message;
}
// 顶部导航 + 布局
const App = {
setup() {
const route = useRoute();
const router = useRouter();
const isActive = (pathPrefix) =>
computed(() => route.path === pathPrefix || route.path.startsWith(pathPrefix));
const loadHealth = async () => {
try {
const res = await fetch("/health", { method: "POST" });
if (res.ok) {
appState.health = await res.json();
}
} catch (error) {
appState.health = null;
handleApiError(error, '健康检查');
}
};
const dismissNotification = (id) => {
const index = appState.notifications.findIndex(n => n.id === id);
if (index > -1) {
appState.notifications.splice(index, 1);
}
};
onMounted(() => {
loadHealth();
// 定时检查健康状态(每30秒)
setInterval(loadHealth, 30000);
});
return {
route,
router,
appState,
isActive,
dismissNotification,
getStatusClass,
statusText
};
},
template: `
<div class="app-shell">
<!-- 通知区域 -->
<div class="notification-container" v-if="appState.notifications.length > 0">
<div
v-for="notification in appState.notifications"
:key="notification.id"
:class="['notification', 'notification-' + notification.type]"
>
<span class="notification-message">{{ notification.message }}</span>
<button class="notification-close" @click="dismissNotification(notification.id)">×</button>
</div>
</div>
<header class="app-header">
<div class="app-title">
<span class="logo">🔧</span>
<div>
<h1>STP 模具几何分析中心</h1>
<p class="app-subtitle">专业模具几何分析,支持型腔自动生成与工艺参数计算</p>
</div>
</div>
<nav class="app-nav">
<router-link :class="['nav-link', { active: isActive('/').value }]" to="/">
📊 仪表盘
</router-link>
<router-link :class="['nav-link', { active: isActive('/history').value }]" to="/history">
📋 历史记录
</router-link>
</nav>
</header>
<main class="app-main">
<router-view />
</main>
<footer class="app-footer">
<div class="footer-left">
<span>PythonOCC: <strong :class="appState.health?.pythonocc ? 'status-success' : 'status-error'">
{{ appState.health?.pythonocc ? '可用' : '未知' }}
</strong></span>
<span v-if="appState.health"> | 当前任务数: {{ appState.health.total_tasks }}</span>
</div>
<div class="footer-right">
<span>版本: 3.0.0</span>
<span> | </span>
<span>模具几何分析系统</span>
</div>
</footer>
</div>
`,
};
// 仪表盘视图:上传 + 总览
const DashboardView = {
setup() {
const router = useRouter();
const state = reactive({
selectedFile: null,
uploading: false,
error: "",
currentTask: null,
polling: false,
historySummary: null,
dragOver: false
});
const totalFiles = computed(() => state.historySummary?.total_files || 0);
const totalRecords = computed(() =>
(state.historySummary?.files || []).reduce(
(sum, f) => sum + (f.record_count || 0),
0
)
);
const latestFile = computed(() =>
(state.historySummary?.files || [])[0] || null
);
const handleFileChange = (event) => {
const file = event.target.files[0];
if (!file) return;
validateAndSelectFile(file);
};
const validateAndSelectFile = (file) => {
if (!file.name.toLowerCase().endsWith(".stp") &&
!file.name.toLowerCase().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 handleDragOver = (event) => {
event.preventDefault();
state.dragOver = true;
};
const handleDragLeave = (event) => {
event.preventDefault();
state.dragOver = false;
};
const handleDrop = (event) => {
event.preventDefault();
state.dragOver = false;
const files = event.dataTransfer.files;
if (files.length > 0) {
validateAndSelectFile(files[0]);
}
};
const uploadFile = async () => {
if (!state.selectedFile) return;
state.uploading = true;
state.error = "";
const formData = new FormData();
formData.append("file", state.selectedFile);
try {
const res = await fetch("/upload", {
method: "POST",
body: formData,
});
if (!res.ok) {
throw new Error(`上传失败: ${res.status} ${res.statusText}`);
}
const data = await res.json();
state.currentTask = {
task_id: data.task_id,
status: "processing",
filename: data.file_info?.filename,
file_size: data.file_info?.size,
};
addNotification(`文件上传成功,开始分析...`, 'success');
startPolling(data.task_id);
} catch (e) {
const errorMsg = handleApiError(e, '文件上传');
state.error = errorMsg;
} finally {
state.uploading = false;
}
};
const startPolling = async (taskId) => {
state.polling = true;
const poll = async () => {
try {
const res = await fetch(`/status/${taskId}`, { method: "POST" });
if (!res.ok) {
throw new Error("查询任务状态失败");
}
const task = await res.json();
state.currentTask = task;
if (task.status === "completed") {
state.polling = false;
addNotification(`分析完成,正在跳转到结果页面...`, 'success');
setTimeout(() => {
router.push(`/result/${task.task_id}`);
}, 1000);
} else if (task.status === "failed") {
state.polling = false;
const errorMsg = task.error || "未知错误";
state.error = `分析失败: ${errorMsg}`;
addNotification(`分析失败: ${errorMsg}`, 'error');
} else {
setTimeout(poll, 1000);
}
} catch (e) {
state.polling = false;
const errorMsg = handleApiError(e, '任务轮询');
state.error = errorMsg;
}
};
poll();
};
const loadHistorySummary = async () => {
try {
const res = await fetch("/api/history", { method: "POST" });
if (res.ok) {
state.historySummary = await res.json();
}
} catch (e) {
state.historySummary = null;
handleApiError(e, '加载历史记录');
}
};
const clearFile = () => {
state.selectedFile = null;
state.error = "";
};
onMounted(() => {
loadHistorySummary();
});
return {
state,
totalFiles,
totalRecords,
latestFile,
handleFileChange,
handleDragOver,
handleDragLeave,
handleDrop,
uploadFile,
clearFile,
formatFileSize,
statusText,
formatDateTime,
getStatusClass
};
},
template: `
<div class="dashboard">
<section class="grid-2">
<div class="card upload-card">
<h2>📁 上传 STP / STEP 文件</h2>
<p class="card-subtitle">上传后系统会自动解析几何、生成模具型腔并计算关键工艺参数。</p>
<div class="upload-panel">
<label class="upload-dropzone">
<input type="file" accept=".stp,.step" @change="handleFileChange" hidden />
<div class="upload-icon">📂</div>
<div v-if="!selectedFile">
<h3>点击选择或拖拽文件到此处</h3>
<p>最大 100MB,支持 .stp / .step</p>
</div>
<div v-else>
<h3>已选择文件:</h3>
<p><strong>{{ selectedFile.name }}</strong></p>
<p>大小:{{ formatFileSize(selectedFile.size) }}</p>
</div>
</label>
<button
class="upload-btn"
:disabled="!selectedFile || uploading"
@click="uploadFile"
>
<span v-if="!uploading">开始分析</span>
<span v-else>正在上传...</span>
</button>
<p v-if="error" class="error-text">{{ error }}</p>
</div>
<div v-if="currentTask" class="card current-task">
<h3>当前任务</h3>
<div class="info-row">
<span>任务 ID:</span><span>{{ currentTask.task_id }}</span>
</div>
<div class="info-row">
<span>文件名:</span><span>{{ currentTask.filename || 'N/A' }}</span>
</div>
<div class="info-row">
<span>文件大小:</span><span>{{ currentTask.file_size ? formatFileSize(currentTask.file_size) : 'N/A' }}</span>
</div>
<div class="info-row">
<span>状态:</span>
<span class="status-pill" :class="'status-' + currentTask.status">
{{ statusText(currentTask.status) }}
</span>
</div>
<div v-if="polling" class="small-hint">正在后台分析,请稍候,完成后会跳转到结果页。</div>
</div>
</div>
<div class="card summary-card">
<h2>📊 项目总览</h2>
<div class="summary-grid">
<div class="summary-item">
<div class="summary-label">已分析文件数</div>
<div class="summary-value">{{ totalFiles }}</div>
</div>
<div class="summary-item">
<div class="summary-label">总处理记录数</div>
<div class="summary-value">{{ totalRecords }}</div>
</div>
<div class="summary-item" v-if="latestFile">
<div class="summary-label">最近上传文件</div>
<div class="summary-value text-left">
<div class="filename">{{ latestFile.filename }}</div>
<div class="meta">
最近上传:{{ formatDateTime(latestFile.last_upload) }}<br />
历史记录:{{ latestFile.record_count }} 条
</div>
</div>
</div>
<div v-else class="summary-empty">
还没有历史记录,先上传一个 STP 文件试试吧。
</div>
</div>
<router-link class="link-btn" to="/history">查看详细历史记录 →</router-link>
</div>
</section>
</div>
`,
};
// 历史记录视图
const HistoryView = {
setup() {
const router = useRouter();
const files = ref([]);
const loading = ref(true);
const error = ref("");
const expanded = ref({});
const loadHistory = async () => {
loading.value = true;
error.value = "";
try {
const res = await fetch("/api/history", { method: "POST" });
if (!res.ok) throw new Error("获取历史记录失败");
const data = await res.json();
files.value = data.files || [];
} catch (e) {
error.value = e.message || "加载失败";
} finally {
loading.value = false;
}
};
const toggleExpand = async (filename) => {
expanded.value[filename] = !expanded.value[filename];
// 首次展开时加载该文件的所有记录
if (expanded.value[filename]) {
const file = files.value.find((f) => f.filename === filename);
if (!file.records) {
try {
const res = await fetch(
`/api/history/${encodeURIComponent(filename)}`,
{ method: "POST" }
);
if (!res.ok) throw new Error("加载记录失败");
file.records = await res.json();
} catch (e) {
error.value = e.message || "加载记录失败";
}
}
}
};
const openResult = (taskId) => {
router.push(`/result/${taskId}`);
};
onMounted(loadHistory);
return {
files,
loading,
error,
expanded,
toggleExpand,
openResult,
formatFileSize,
formatDateTime,
statusText,
};
},
template: `
<div class="history-view">
<h2>📋 历史记录</h2>
<p class="card-subtitle">按文件名聚合展示所有上传与分析任务。</p>
<div v-if="loading" class="center-block">
<div class="spinner"></div>
<p>正在加载历史记录...</p>
</div>
<p v-if="error" class="error-text">{{ error }}</p>
<div v-if="!loading && files.length === 0 && !error" class="center-block">
<div style="font-size: 40px; margin-bottom: 12px;">📁</div>
<p>暂无历史记录,先在仪表盘上传一个文件吧。</p>
</div>
<div class="file-list" v-if="files.length">
<div
v-for="file in files"
:key="file.filename"
class="file-card"
:class="{ expanded: expanded[file.filename] }"
>
<div class="file-header" @click="toggleExpand(file.filename)">
<div>
<div class="file-name">📄 {{ file.filename }}</div>
<div class="file-meta">
总记录:{{ file.record_count }} 条
<span v-if="file.last_upload"> | 最近上传:{{ formatDateTime(file.last_upload) }}</span>
</div>
</div>
<button class="expand-btn">
{{ expanded[file.filename] ? '收起' : '展开' }}
</button>
</div>
<div class="record-list" v-if="expanded[file.filename]">
<div v-if="!file.records" class="center-block">
<div class="spinner small"></div>
<p>正在加载记录...</p>
</div>
<div v-else-if="file.records.length === 0" class="center-block">
暂无详细记录
</div>
<div
v-else
v-for="record in file.records"
:key="record.task_id"
class="record-item"
@click="openResult(record.task_id)"
>
<div class="record-header">
<span>📅 {{ formatDateTime(record.upload_time) }}</span>
<span class="status-pill" :class="'status-' + record.status">
{{ statusText(record.status) }}
</span>
</div>
<div class="record-meta">
<span>任务 ID:{{ record.task_id }}</span>
<span>文件大小:{{ formatFileSize(record.file_size) }}</span>
<span v-if="record.completed_at">完成时间:{{ formatDateTime(record.completed_at) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
`,
};
// 结果详情视图
const ResultView = {
setup() {
const route = useRoute();
const task = ref(null);
const loading = ref(true);
const error = ref("");
const taskId = computed(() => route.params.taskId);
const loadTask = async () => {
loading.value = true;
error.value = "";
try {
const res = await fetch(`/status/${taskId.value}`, { method: "POST" });
if (!res.ok) throw new Error("获取任务数据失败");
const data = await res.json();
task.value = data;
} catch (e) {
error.value = e.message || "加载失败";
} finally {
loading.value = false;
}
};
const geometry = computed(() => task.value?.geometry_data || null);
const keyInfo = computed(() => task.value?.key_info || null);
const meshSummary = computed(() => task.value?.mesh_summary || null);
onMounted(loadTask);
return {
task,
loading,
error,
taskId,
geometry,
keyInfo,
meshSummary,
formatNumber,
formatFileSize,
formatDateTime,
statusText,
};
},
template: `
<div class="result-view">
<h2>📊 分析结果详情</h2>
<div v-if="loading" class="center-block">
<div class="spinner"></div>
<p>正在加载任务 {{ taskId }} 的分析结果...</p>
</div>
<p v-if="error" class="error-text">{{ error }}</p>
<div v-if="task && !loading" class="result-layout">
<section class="card task-card">
<h3>📝 任务信息</h3>
<div class="info-grid">
<div class="info-item">
<span class="info-label">任务 ID</span>
<span class="info-value">{{ task.task_id || 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">文件名</span>
<span class="info-value">{{ task.filename || 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">文件大小</span>
<span class="info-value">{{ task.file_size ? formatFileSize(task.file_size) : 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">状态</span>
<span class="info-value">
<span class="status-pill" :class="'status-' + task.status">
{{ statusText(task.status) }}
</span>
</span>
</div>
<div class="info-item">
<span class="info-label">上传时间</span>
<span class="info-value">{{ task.upload_time ? formatDateTime(task.upload_time) : 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">完成时间</span>
<span class="info-value">{{ task.completed_at ? formatDateTime(task.completed_at) : 'N/A' }}</span>
</div>
</div>
<div v-if="task.error" class="error-box">
<strong>错误信息:</strong>{{ task.error }}
</div>
</section>
<section class="grid-3">
<div class="card">
<h3>📐 几何属性</h3>
<div v-if="geometry" class="data-grid">
<div class="data-item">
<span class="data-label">体积</span>
<span class="data-value">
{{ geometry.volume ? formatNumber(geometry.volume) + ' mm³' : 'N/A' }}
</span>
</div>
<div class="data-item">
<span class="data-label">表面积</span>
<span class="data-value">
{{ geometry.surface_area ? formatNumber(geometry.surface_area) + ' mm²' : 'N/A' }}
</span>
</div>
<div class="data-item">
<span class="data-label">体积/面积比</span>
<span class="data-value">
{{
geometry.volume && geometry.surface_area
? (geometry.volume / geometry.surface_area).toFixed(4)
: 'N/A'
}}
</span>
</div>
</div>
<div v-else class="placeholder">无几何数据</div>
</div>
<div class="card">
<h3>📦 边界框</h3>
<div v-if="geometry?.bounding_box" class="data-grid">
<div class="data-item">
<span class="data-label">尺寸 (mm)</span>
<span class="data-value">
{{ geometry.bounding_box.dimensions[0].toFixed(2) }} ×
{{ geometry.bounding_box.dimensions[1].toFixed(2) }} ×
{{ geometry.bounding_box.dimensions[2].toFixed(2) }}
</span>
</div>
<div class="data-item">
<span class="data-label">最小坐标</span>
<span class="data-value">
X: {{ geometry.bounding_box.min[0].toFixed(2) }},
Y: {{ geometry.bounding_box.min[1].toFixed(2) }},
Z: {{ geometry.bounding_box.min[2].toFixed(2) }}
</span>
</div>
<div class="data-item">
<span class="data-label">最大坐标</span>
<span class="data-value">
X: {{ geometry.bounding_box.max[0].toFixed(2) }},
Y: {{ geometry.bounding_box.max[1].toFixed(2) }},
Z: {{ geometry.bounding_box.max[2].toFixed(2) }}
</span>
</div>
</div>
<div v-else class="placeholder">无边界框数据</div>
</div>
<div class="card">
<h3>🔺 拓扑结构</h3>
<div v-if="geometry?.topology" class="data-grid">
<div class="data-item">
<span class="data-label">面数</span>
<span class="data-value">{{ geometry.topology.faces || 0 }}</span>
</div>
<div class="data-item">
<span class="data-label">边数</span>
<span class="data-value">{{ geometry.topology.edges || 0 }}</span>
</div>
<div class="data-item">
<span class="data-label">顶点数</span>
<span class="data-value">{{ geometry.topology.vertices || 0 }}</span>
</div>
</div>
<div v-else class="placeholder">无拓扑数据</div>
</div>
</section>
<section class="grid-2">
<div class="card">
<h3>🔧 模具 / 工艺关键信息</h3>
<div v-if="keyInfo" class="data-grid">
<div class="data-item">
<span class="data-label">收缩率</span>
<span class="data-value">
{{ keyInfo.metadata?.shrinkage_rate ?? 'N/A' }}
</span>
</div>
<div class="data-item">
<span class="data-label">拔模角</span>
<span class="data-value">
{{
keyInfo.metadata?.draft_angle !== undefined
? keyInfo.metadata.draft_angle + '°'
: 'N/A'
}}
</span>
</div>
<div class="data-item">
<span class="data-label">预估夹紧力</span>
<span class="data-value">
{{ keyInfo.manufacturing_info?.estimated_clamping_force || 'N/A' }}
</span>
</div>
<div class="data-item">
<span class="data-label">模具尺寸 (mm)</span>
<span class="data-value">
{{
keyInfo.manufacturing_info?.estimated_mold_size
? keyInfo.manufacturing_info.estimated_mold_size.length +
' × ' +
keyInfo.manufacturing_info.estimated_mold_size.width +
' × ' +
keyInfo.manufacturing_info.estimated_mold_size.height
: 'N/A'
}}
</span>
</div>
<div class="data-item">
<span class="data-label">产品重量</span>
<span class="data-value">
{{
keyInfo.mold_cavities?.cavity_key_info?.geometric_characteristics
?.product_weight || 'N/A'
}}
</span>
</div>
<div class="data-item">
<span class="data-label">壁厚范围</span>
<span class="data-value">
{{
keyInfo.mold_cavities?.cavity_key_info?.geometric_characteristics
?.wall_thickness_range || 'N/A'
}}
</span>
</div>
<div class="data-item">
<span class="data-label">预估成型周期</span>
<span class="data-value">
{{ keyInfo.manufacturing_info?.estimated_cycle_time || 'N/A' }}
</span>
</div>
</div>
<div v-else class="placeholder">无型腔 / 工艺数据</div>
</div>
<div class="card">
<h3>🧱 网格摘要</h3>
<div v-if="meshSummary" class="data-grid">
<div class="data-item">
<span class="data-label">网格质量等级</span>
<span class="data-value">{{ meshSummary.quality }}</span>
</div>
<div class="data-item">
<span class="data-label">顶点数</span>
<span class="data-value">{{ meshSummary.vertex_count }}</span>
</div>
<div class="data-item">
<span class="data-label">面数</span>
<span class="data-value">{{ meshSummary.face_count }}</span>
</div>
<div class="data-item">
<span class="data-label">点云采样点数</span>
<span class="data-value">{{ meshSummary.point_count ?? 'N/A' }}</span>
</div>
</div>
<div v-else class="placeholder">
当前任务未生成网格摘要,或网格生成过程中出现问题。
</div>
</div>
</section>
</div>
</div>
`,
};
// 路由配置
const routes = [
{ path: "/", component: DashboardView },
{ path: "/history", component: HistoryView },
{ path: "/result/:taskId", component: ResultView },
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// 挂载应用
createApp(App).use(router).mount("#app");