Files
geMoldInsight/static/vue-app.js
T
2026-03-04 00:10:05 +08:00

888 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单页应用
* 版本: 5.0.0 - Modern Minimal UI
* 设计灵感: Linear, Notion, Vercel, Stripe
*/
const { createApp, ref, computed, onMounted, reactive, watch, nextTick } = Vue;
const { createRouter, createWebHistory, useRoute, useRouter } = VueRouter;
const appState = reactive({
health: null,
loading: false,
notifications: [],
theme: 'modern'
});
function formatFileSize(bytes) {
if (!bytes || bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "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: "badge-warning",
processing: "badge-info",
completed: "badge-success",
failed: "badge-error"
};
return classMap[status] || "badge-neutral";
}
let notificationId = 0;
function addNotification(message, type = 'info') {
const id = ++notificationId;
const notification = {
id,
message,
type,
timestamp: new Date(),
visible: true
};
appState.notifications.push(notification);
setTimeout(() => {
const index = appState.notifications.findIndex(n => n.id === id);
if (index > -1) {
appState.notifications[index].visible = false;
setTimeout(() => {
const idx = appState.notifications.findIndex(n => n.id === id);
if (idx > -1) {
appState.notifications.splice(idx, 1);
}
}, 300);
}
}, 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;
}
};
const dismissNotification = (id) => {
const index = appState.notifications.findIndex(n => n.id === id);
if (index > -1) {
appState.notifications[index].visible = false;
setTimeout(() => {
const idx = appState.notifications.findIndex(n => n.id === id);
if (idx > -1) {
appState.notifications.splice(idx, 1);
}
}, 300);
}
};
onMounted(() => {
loadHealth();
setInterval(loadHealth, 30000);
});
return {
route,
router,
appState,
isActive,
dismissNotification,
getStatusClass,
statusText
};
},
template: `
<div class="app-container">
<div class="notification-container" v-if="appState.notifications.length > 0">
<TransitionGroup name="notification">
<div
v-for="notification in appState.notifications"
:key="notification.id"
:class="['notification', 'notification-' + notification.type]"
>
<div class="notification-icon">
{{ notification.type === 'success' ? '✓' : notification.type === 'error' ? '✕' : notification.type === 'warning' ? '!' : 'i' }}
</div>
<div class="notification-content">
<div class="notification-message">{{ notification.message }}</div>
</div>
<button class="notification-close" @click="dismissNotification(notification.id)">×</button>
</div>
</TransitionGroup>
</div>
<header class="app-header">
<div class="header-content">
<div class="logo">
<div class="logo-icon">◈</div>
<div>
<div class="logo-text">MoldInsight</div>
<div class="logo-subtitle">STP 模具几何分析</div>
</div>
</div>
<nav class="nav-menu">
<router-link :class="['nav-item', { active: isActive('/').value }]" to="/">
仪表盘
</router-link>
<router-link :class="['nav-item', { active: isActive('/history').value }]" to="/history">
历史记录
</router-link>
</nav>
<div class="user-section">
<div class="status-badge">
<span class="status-dot"></span>
<span>系统运行中</span>
</div>
</div>
</div>
</header>
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<footer class="app-footer">
<div class="footer-content">
<span class="footer-item">
<span class="footer-label">PythonOCC:</span>
<strong>{{ appState.health?.pythonocc ? '可用' : '检测中...' }}</strong>
</span>
<span class="footer-divider"></span>
<span v-if="appState.health" class="footer-item">
<span class="footer-label">任务数:</span>
<strong>{{ appState.health.total_tasks }}</strong>
</span>
<span class="footer-divider"></span>
<span class="footer-item">版本 5.0.0</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,
progress: 0
});
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 = "";
state.progress = 0;
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;
state.progress = 10;
let pollCount = 0;
const maxPolls = 300;
const poll = async () => {
try {
pollCount++;
state.progress = Math.min(90, 10 + pollCount * 0.5);
const res = await fetch(`/status/${taskId}`, { method: "POST" });
if (res.status === 404) {
state.polling = false;
state.error = "任务不存在或已被删除";
addNotification("任务不存在或已被删除", 'error');
return;
}
if (!res.ok) {
throw new Error(`查询任务状态失败: ${res.status} ${res.statusText}`);
}
const task = await res.json();
state.currentTask = task;
if (task.status === "completed") {
state.polling = false;
state.progress = 100;
addNotification(`分析完成,正在跳转...`, 'success');
setTimeout(() => {
router.push(`/result/${task.task_id}`);
}, 1000);
} else if (task.status === "failed") {
state.polling = false;
state.progress = 0;
const errorMsg = task.error || "未知错误";
state.error = `分析失败: ${errorMsg}`;
addNotification(`分析失败: ${errorMsg}`, 'error');
} else if (pollCount >= maxPolls) {
state.polling = false;
state.progress = 0;
state.error = "任务处理超时,请稍后查看结果";
addNotification("任务处理超时", 'warning');
} else {
setTimeout(poll, 1000);
}
} catch (e) {
state.polling = false;
state.progress = 0;
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;
}
};
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>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">📁</div>
<div class="stat-value">{{ totalFiles }}</div>
<div class="stat-label">已分析文件</div>
</div>
<div class="stat-card">
<div class="stat-icon">📊</div>
<div class="stat-value">{{ totalRecords }}</div>
<div class="stat-label">处理记录</div>
</div>
<div class="stat-card">
<div class="stat-icon">⚡</div>
<div class="stat-value">{{ state.polling ? '...' : '就绪' }}</div>
<div class="stat-label">系统状态</div>
</div>
<div class="stat-card">
<div class="stat-icon">🎯</div>
<div class="stat-value">{{ latestFile ? '1' : '0' }}</div>
<div class="stat-label">最近文件</div>
</div>
</div>
<div class="content-grid">
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="card-title-icon">📤</div>
上传文件
</div>
</div>
<div class="card-body">
<label
class="upload-zone"
:class="{ 'drag-over': state.dragOver }"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<input type="file" accept=".stp,.step" @change="handleFileChange" hidden />
<div class="upload-icon">⬆</div>
<div v-if="!state.selectedFile" class="upload-title">点击选择或拖拽文件</div>
<div v-if="!state.selectedFile" class="upload-hint">支持 .stp / .step 格式,最大 100MB</div>
<div v-else class="upload-title">{{ state.selectedFile.name }}</div>
<div v-if="state.selectedFile" class="upload-hint">{{ formatFileSize(state.selectedFile.size) }}</div>
<div class="upload-formats">
<span class="format-tag">.stp</span>
<span class="format-tag">.step</span>
</div>
</label>
<div class="flex gap-2 mt-4">
<button
v-if="state.selectedFile"
class="btn btn-secondary"
@click="clearFile"
>
清除
</button>
<button
class="btn btn-primary btn-full"
:disabled="!state.selectedFile || state.uploading"
@click="uploadFile"
>
<span v-if="state.uploading" class="loading-spinner"></span>
<span v-if="!state.uploading">开始分析</span>
<span v-else>上传中...</span>
</button>
</div>
<p v-if="state.error" class="badge badge-error mt-4">{{ state.error }}</p>
<div v-if="state.currentTask" class="mt-6">
<div class="flex items-center justify-between mb-2">
<span class="text-secondary">当前任务</span>
<span :class="['badge', getStatusClass(state.currentTask.status)]">
{{ statusText(state.currentTask.status) }}
</span>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: state.progress + '%' }"></div>
</div>
<div class="text-muted text-sm mt-2">{{ state.currentTask.filename }}</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="card-title-icon">📊</div>
项目总览
</div>
</div>
<div class="card-body">
<div v-if="latestFile" class="file-item">
<div class="file-icon">📄</div>
<div class="file-info">
<div class="file-name">{{ latestFile.filename }}</div>
<div class="file-meta">
<span>{{ latestFile.record_count }} 条记录</span>
<span>{{ formatDateTime(latestFile.last_upload) }}</span>
</div>
</div>
</div>
<div v-else class="empty-state">
<div class="empty-icon">📁</div>
<div class="empty-title">暂无历史记录</div>
<div class="empty-description">上传一个 STP 文件开始分析</div>
</div>
<router-link class="btn btn-secondary btn-full mt-6" to="/history">
查看历史记录
</router-link>
</div>
</div>
</div>
</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>
<div class="result-header">
<div class="result-title">
📋 历史记录
</div>
</div>
<div v-if="loading" class="flex items-center justify-center gap-4" style="padding: 4rem;">
<div class="loading-spinner"></div>
<span class="text-secondary">加载中...</span>
</div>
<p v-if="error" class="badge badge-error">{{ error }}</p>
<div v-if="!loading && files.length === 0 && !error" class="empty-state">
<div class="empty-icon">📁</div>
<div class="empty-title">暂无历史记录</div>
<div class="empty-description">上传文件后这里会显示分析历史</div>
</div>
<TransitionGroup name="list" tag="div" class="file-list" v-if="files.length">
<div
v-for="file in files"
:key="file.filename"
class="card"
style="margin-bottom: var(--space-4);"
>
<div class="file-header" @click="toggleExpand(file.filename)">
<div class="file-info">
<div class="file-name">{{ file.filename }}</div>
<div class="file-meta">
{{ file.record_count }} 条记录 · {{ formatDateTime(file.last_upload) }}
</div>
</div>
<button class="btn btn-ghost">
{{ expanded[file.filename] ? '收起' : '展开' }}
</button>
</div>
<transition name="slide-up">
<div v-if="expanded[file.filename]" style="padding: var(--space-4); border-top: 1px solid var(--border-light);">
<div v-if="!file.records" class="flex items-center justify-center gap-2">
<div class="loading-spinner"></div>
<span class="text-secondary">加载中...</span>
</div>
<div v-else-if="file.records.length === 0" class="text-center text-muted">
暂无详细记录
</div>
<TransitionGroup name="list" tag="div" v-else class="file-list">
<div
v-for="record in file.records"
:key="record.task_id"
class="file-item"
@click="openResult(record.task_id)"
>
<div class="file-icon">📊</div>
<div class="file-info">
<div class="file-name">{{ record.task_id.slice(0, 8) }}...</div>
<div class="file-meta">
<span>{{ formatDateTime(record.upload_time) }}</span>
<span>{{ formatFileSize(record.file_size) }}</span>
</div>
</div>
<span :class="['badge', record.status === 'completed' ? 'badge-success' : record.status === 'failed' ? 'badge-error' : 'badge-warning']">
{{ statusText(record.status) }}
</span>
</div>
</TransitionGroup>
</div>
</transition>
</div>
</TransitionGroup>
</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>
<div class="result-header">
<div class="result-title">
📊 分析结果
</div>
<router-link class="btn btn-secondary" to="/">返回仪表盘</router-link>
</div>
<div v-if="loading" class="flex items-center justify-center gap-4" style="padding: 4rem;">
<div class="loading-spinner"></div>
<span class="text-secondary">加载中...</span>
</div>
<p v-if="error" class="badge badge-error">{{ error }}</p>
<div v-if="task && !loading">
<div class="card mb-6">
<div class="card-header">
<div class="card-title">
<div class="card-title-icon">📝</div>
任务信息
</div>
<span :class="['badge', task.status === 'completed' ? 'badge-success' : task.status === 'failed' ? 'badge-error' : 'badge-info']">
{{ statusText(task.status) }}
</span>
</div>
<div class="card-body">
<div class="grid-2">
<div class="file-item">
<div class="file-icon">📄</div>
<div class="file-info">
<div class="file-name">{{ task.filename || 'N/A' }}</div>
<div class="file-meta">文件名</div>
</div>
</div>
<div class="file-item">
<div class="file-icon">💾</div>
<div class="file-info">
<div class="file-name">{{ task.file_size ? formatFileSize(task.file_size) : 'N/A' }}</div>
<div class="file-meta">文件大小</div>
</div>
</div>
<div class="file-item">
<div class="file-icon">🆔</div>
<div class="file-info">
<div class="file-name font-mono">{{ task.task_id }}</div>
<div class="file-meta">任务 ID</div>
</div>
</div>
<div class="file-item">
<div class="file-icon">📅</div>
<div class="file-info">
<div class="file-name">{{ task.upload_time ? formatDateTime(task.upload_time) : 'N/A' }}</div>
<div class="file-meta">上传时间</div>
</div>
</div>
</div>
<div v-if="task.error" class="badge badge-error mt-4">
错误: {{ task.error }}
</div>
</div>
</div>
<div class="result-grid">
<div class="result-card">
<div class="result-card-header">
<div class="result-card-icon" style="background: var(--primary-50); color: var(--primary-600);">📐</div>
<div class="result-card-title">几何属性</div>
</div>
<div v-if="geometry" class="result-card-value">
<div class="mb-4">
<div class="text-muted text-sm">体积</div>
<div>{{ geometry.volume ? formatNumber(geometry.volume) : 'N/A' }} mm³</div>
</div>
<div class="mb-4">
<div class="text-muted text-sm">表面积</div>
<div>{{ geometry.surface_area ? formatNumber(geometry.surface_area) : 'N/A' }} mm²</div>
</div>
</div>
<div v-else class="text-muted">无几何数据</div>
</div>
<div class="result-card">
<div class="result-card-header">
<div class="result-card-icon" style="background: var(--accent-50); color: var(--accent-600);">📦</div>
<div class="result-card-title">边界框</div>
</div>
<div v-if="geometry?.bounding_box" class="result-card-value">
<div class="mb-4">
<div class="text-muted text-sm">尺寸</div>
<div>
{{ geometry.bounding_box.dimensions[0].toFixed(2) }} ×
{{ geometry.bounding_box.dimensions[1].toFixed(2) }} ×
{{ geometry.bounding_box.dimensions[2].toFixed(2) }} mm
</div>
</div>
</div>
<div v-else class="text-muted">无边界框数据</div>
</div>
<div class="result-card">
<div class="result-card-header">
<div class="result-card-icon" style="background: var(--warning-bg); color: var(--warning);">🔺</div>
<div class="result-card-title">拓扑结构</div>
</div>
<div v-if="geometry?.topology" class="result-card-value">
<div class="mb-4">
<div class="text-muted text-sm">面 / 边 / 顶点</div>
<div>{{ geometry.topology.faces || 0 }} / {{ geometry.topology.edges || 0 }} / {{ geometry.topology.vertices || 0 }}</div>
</div>
</div>
<div v-else class="text-muted">无拓扑数据</div>
</div>
</div>
<div v-if="keyInfo" class="card mt-6">
<div class="card-header">
<div class="card-title">
<div class="card-title-icon">🔧</div>
工艺参数
</div>
</div>
<div class="card-body">
<div class="grid-2">
<div class="file-item" v-if="keyInfo.metadata?.shrinkage_rate">
<div class="file-icon">📉</div>
<div class="file-info">
<div class="file-name">{{ keyInfo.metadata.shrinkage_rate }}</div>
<div class="file-meta">收缩率</div>
</div>
</div>
<div class="file-item" v-if="keyInfo.metadata?.draft_angle !== undefined">
<div class="file-icon">📐</div>
<div class="file-info">
<div class="file-name">{{ keyInfo.metadata.draft_angle }}°</div>
<div class="file-meta">拔模角</div>
</div>
</div>
<div class="file-item" v-if="keyInfo.manufacturing_info?.estimated_clamping_force">
<div class="file-icon">💪</div>
<div class="file-info">
<div class="file-name">{{ keyInfo.manufacturing_info.estimated_clamping_force }}</div>
<div class="file-meta">预估夹紧力</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`,
};
const routes = [
{ path: "/", component: DashboardView },
{ path: "/history", component: HistoryView },
{ path: "/result/:taskId", component: ResultView },
];
const router = createRouter({
history: createWebHistory(),
routes,
});
const app = createApp(App);
app.use(router);
app.mount("#app");