2026-02-17 00:42:55 +08:00
|
|
|
|
/**
|
2026-03-04 00:47:41 +08:00
|
|
|
|
* Gemold - 模具制造管理系统
|
|
|
|
|
|
* 版本: 4.0.0
|
2026-02-17 00:42:55 +08:00
|
|
|
|
*/
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
2026-03-03 23:57:04 +08:00
|
|
|
|
const { createApp, ref, computed, onMounted, reactive, watch, nextTick } = Vue;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
const { createRouter, createWebHistory, useRoute, useRouter } = VueRouter;
|
|
|
|
|
|
|
2026-02-17 00:42:55 +08:00
|
|
|
|
const appState = reactive({
|
2026-03-04 00:47:41 +08:00
|
|
|
|
user: null,
|
|
|
|
|
|
token: null,
|
2026-02-17 00:42:55 +08:00
|
|
|
|
loading: false,
|
2026-03-03 23:57:04 +08:00
|
|
|
|
notifications: [],
|
2026-03-04 00:47:41 +08:00
|
|
|
|
initialized: false
|
2026-02-17 00:42:55 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-17 00:12:36 +08:00
|
|
|
|
function formatFileSize(bytes) {
|
2026-03-04 00:10:05 +08:00
|
|
|
|
if (!bytes || bytes === 0) return "0 B";
|
2026-02-17 00:12:36 +08:00
|
|
|
|
const k = 1024;
|
2026-03-04 00:10:05 +08:00
|
|
|
|
const sizes = ["B", "KB", "MB", "GB"];
|
2026-02-17 00:12:36 +08:00
|
|
|
|
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 {
|
2026-03-08 01:56:02 +08:00
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
|
if (isNaN(date.getTime())) return dateString;
|
|
|
|
|
|
const year = date.getFullYear();
|
|
|
|
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
|
|
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
|
|
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
|
|
|
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
|
|
|
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
|
|
|
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
return dateString;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
function formatDate(dateString) {
|
|
|
|
|
|
if (!dateString) return "N/A";
|
|
|
|
|
|
try {
|
|
|
|
|
|
return new Date(dateString).toLocaleDateString('zh-CN');
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return dateString;
|
|
|
|
|
|
}
|
2026-02-17 00:12:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
function formatCurrency(amount) {
|
|
|
|
|
|
if (amount === null || amount === undefined) return "¥0.00";
|
|
|
|
|
|
return "¥" + Number(amount).toFixed(2);
|
2026-02-17 00:42:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 23:57:04 +08:00
|
|
|
|
let notificationId = 0;
|
|
|
|
|
|
|
2026-02-17 00:42:55 +08:00
|
|
|
|
function addNotification(message, type = 'info') {
|
2026-03-03 23:57:04 +08:00
|
|
|
|
const id = ++notificationId;
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const notification = { id, message, type, timestamp: new Date(), visible: true };
|
2026-02-17 00:42:55 +08:00
|
|
|
|
appState.notifications.push(notification);
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
2026-03-03 23:57:04 +08:00
|
|
|
|
const index = appState.notifications.findIndex(n => n.id === id);
|
2026-02-17 00:42:55 +08:00
|
|
|
|
if (index > -1) {
|
2026-03-03 23:57:04 +08:00
|
|
|
|
appState.notifications[index].visible = false;
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const idx = appState.notifications.findIndex(n => n.id === id);
|
2026-03-04 00:47:41 +08:00
|
|
|
|
if (idx > -1) appState.notifications.splice(idx, 1);
|
2026-03-03 23:57:04 +08:00
|
|
|
|
}, 300);
|
2026-02-17 00:42:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleApiError(error, context = '') {
|
|
|
|
|
|
console.error(`API错误 [${context}]:`, error);
|
|
|
|
|
|
const message = error.message || '请求失败,请稍后重试';
|
|
|
|
|
|
addNotification(message, 'error');
|
|
|
|
|
|
return message;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
async function apiRequest(url, options = {}) {
|
|
|
|
|
|
const headers = {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
...options.headers
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (appState.token) {
|
|
|
|
|
|
headers['Authorization'] = `Bearer ${appState.token}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetch(url, { ...options, headers });
|
|
|
|
|
|
|
|
|
|
|
|
if (response.status === 401) {
|
|
|
|
|
|
appState.user = null;
|
|
|
|
|
|
appState.token = null;
|
|
|
|
|
|
localStorage.removeItem('token');
|
|
|
|
|
|
localStorage.removeItem('user');
|
|
|
|
|
|
throw new Error('登录已过期,请重新登录');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.json().catch(() => ({ detail: '请求失败' }));
|
|
|
|
|
|
throw new Error(error.detail || '请求失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return response.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveAuth(token, user) {
|
|
|
|
|
|
appState.token = token;
|
|
|
|
|
|
appState.user = user;
|
|
|
|
|
|
localStorage.setItem('token', token);
|
|
|
|
|
|
localStorage.setItem('user', JSON.stringify(user));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearAuth() {
|
|
|
|
|
|
appState.token = null;
|
|
|
|
|
|
appState.user = null;
|
|
|
|
|
|
localStorage.removeItem('token');
|
|
|
|
|
|
localStorage.removeItem('user');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function initAuth() {
|
|
|
|
|
|
const token = localStorage.getItem('token');
|
|
|
|
|
|
const userStr = localStorage.getItem('user');
|
|
|
|
|
|
|
|
|
|
|
|
if (token && userStr) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
appState.token = token;
|
|
|
|
|
|
appState.user = JSON.parse(userStr);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
clearAuth();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
appState.initialized = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 00:12:36 +08:00
|
|
|
|
const App = {
|
|
|
|
|
|
setup() {
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const menuItems = computed(() => {
|
|
|
|
|
|
const items = [
|
|
|
|
|
|
{ path: '/', label: '首页', icon: '⌂' },
|
|
|
|
|
|
{ path: '/moldinsight', label: 'MoldInsight', icon: '◈' },
|
|
|
|
|
|
{ path: '/inventory', label: '进销存', icon: '⊞' }
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
if (appState.user?.is_superuser) {
|
|
|
|
|
|
items.push({ path: '/users', label: '用户管理', icon: '👤' });
|
2026-02-17 00:12:36 +08:00
|
|
|
|
}
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
|
|
|
|
|
return items;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const isActive = (path) => {
|
|
|
|
|
|
if (path === '/') return route.path === '/';
|
|
|
|
|
|
return route.path.startsWith(path);
|
2026-02-17 00:12:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const handleLogout = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiRequest('/api/auth/logout', { method: 'POST' });
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
clearAuth();
|
|
|
|
|
|
addNotification('已退出登录', 'success');
|
|
|
|
|
|
router.push('/login');
|
2026-02-17 00:42:55 +08:00
|
|
|
|
};
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
2026-02-17 00:42:55 +08:00
|
|
|
|
onMounted(() => {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
initAuth();
|
2026-02-17 00:42:55 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-06 00:52:06 +08:00
|
|
|
|
const getPriorityText = (priority) => {
|
|
|
|
|
|
const priorityMap = {
|
|
|
|
|
|
'critical': '紧急',
|
|
|
|
|
|
'high': '高',
|
|
|
|
|
|
'medium': '中',
|
|
|
|
|
|
'low': '低'
|
|
|
|
|
|
};
|
|
|
|
|
|
return priorityMap[priority] || priority;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-17 00:42:55 +08:00
|
|
|
|
return {
|
|
|
|
|
|
route,
|
|
|
|
|
|
router,
|
|
|
|
|
|
appState,
|
2026-03-04 00:47:41 +08:00
|
|
|
|
menuItems,
|
2026-02-17 00:42:55 +08:00
|
|
|
|
isActive,
|
2026-03-04 00:47:41 +08:00
|
|
|
|
handleLogout,
|
2026-03-06 00:52:06 +08:00
|
|
|
|
getPriorityText,
|
2026-03-04 00:47:41 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-17 00:42:55 +08:00
|
|
|
|
};
|
2026-02-17 00:12:36 +08:00
|
|
|
|
},
|
|
|
|
|
|
template: `
|
2026-03-04 00:10:05 +08:00
|
|
|
|
<div class="app-container">
|
2026-02-17 00:42:55 +08:00
|
|
|
|
<div class="notification-container" v-if="appState.notifications.length > 0">
|
2026-03-03 23:57:04 +08:00
|
|
|
|
<TransitionGroup name="notification">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="notification in appState.notifications"
|
|
|
|
|
|
:key="notification.id"
|
2026-03-04 00:10:05 +08:00
|
|
|
|
:class="['notification', 'notification-' + notification.type]"
|
2026-03-03 23:57:04 +08:00
|
|
|
|
>
|
2026-03-04 00:10:05 +08:00
|
|
|
|
<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>
|
2026-03-03 23:57:04 +08:00
|
|
|
|
<button class="notification-close" @click="dismissNotification(notification.id)">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TransitionGroup>
|
2026-02-17 00:42:55 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-17 00:12:36 +08:00
|
|
|
|
<header class="app-header">
|
2026-03-04 00:10:05 +08:00
|
|
|
|
<div class="header-content">
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="logo" @click="router.push('/')">
|
2026-03-07 00:41:05 +08:00
|
|
|
|
<div class="logo-icon">G</div>
|
2026-03-04 00:10:05 +08:00
|
|
|
|
<div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="logo-text">Gemold</div>
|
|
|
|
|
|
<div class="logo-subtitle">模具制造管理系统</div>
|
2026-03-04 00:10:05 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<nav class="nav-menu" v-if="appState.user">
|
|
|
|
|
|
<router-link
|
|
|
|
|
|
v-for="item in menuItems"
|
|
|
|
|
|
:key="item.path"
|
|
|
|
|
|
:to="item.path"
|
|
|
|
|
|
:class="['nav-item', { active: isActive(item.path) }]"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ item.label }}
|
2026-03-04 00:10:05 +08:00
|
|
|
|
</router-link>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="user-section">
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<template v-if="appState.user">
|
|
|
|
|
|
<div class="user-info">
|
2026-03-07 00:41:05 +08:00
|
|
|
|
<div class="user-avatar">{{ (appState.user.full_name || appState.user.username).charAt(0).toUpperCase() }}</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<span class="user-name">{{ appState.user.full_name || appState.user.username }}</span>
|
|
|
|
|
|
</div>
|
2026-03-07 00:41:05 +08:00
|
|
|
|
<button class="btn btn-secondary btn-sm" @click="handleLogout">退出</button>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
2026-03-07 00:41:05 +08:00
|
|
|
|
<router-link to="/login" class="btn btn-primary btn-sm">登录</router-link>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</template>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-03-04 00:10:05 +08:00
|
|
|
|
<main class="main-content">
|
2026-03-03 23:57:04 +08:00
|
|
|
|
<router-view v-slot="{ Component }">
|
2026-03-04 00:10:05 +08:00
|
|
|
|
<transition name="fade" mode="out-in">
|
2026-03-03 23:57:04 +08:00
|
|
|
|
<component :is="Component" />
|
|
|
|
|
|
</transition>
|
|
|
|
|
|
</router-view>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const LoginView = {
|
|
|
|
|
|
setup() {
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const state = reactive({
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
loading: false,
|
|
|
|
|
|
error: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
if (appState.user) {
|
|
|
|
|
|
router.push('/');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async () => {
|
|
|
|
|
|
if (!state.username || !state.password) {
|
|
|
|
|
|
state.error = '请填写用户名和密码';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
state.error = '';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-04 01:08:00 +08:00
|
|
|
|
const res = await fetch('/api/auth/login/json', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
username: state.username,
|
|
|
|
|
|
password: state.password
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const error = await res.json();
|
|
|
|
|
|
throw new Error(error.detail || '登录失败');
|
2026-03-04 00:47:41 +08:00
|
|
|
|
}
|
2026-03-04 01:08:00 +08:00
|
|
|
|
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
saveAuth(data.access_token, data.user);
|
|
|
|
|
|
addNotification('登录成功', 'success');
|
|
|
|
|
|
router.push('/');
|
2026-03-04 00:47:41 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
state.error = e.message;
|
|
|
|
|
|
addNotification(e.message, 'error');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return { state, handleSubmit };
|
|
|
|
|
|
},
|
|
|
|
|
|
template: `
|
2026-03-07 00:41:05 +08:00
|
|
|
|
<div class="login-container">
|
|
|
|
|
|
<div class="login-card">
|
|
|
|
|
|
<div class="login-header">
|
|
|
|
|
|
<div class="login-logo">G</div>
|
|
|
|
|
|
<h1 class="login-title">欢迎回来</h1>
|
|
|
|
|
|
<p class="login-subtitle">登录到 Gemold 系统</p>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-07 00:41:05 +08:00
|
|
|
|
<form @submit.prevent="handleSubmit" class="login-form">
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="form-group">
|
2026-03-07 00:41:05 +08:00
|
|
|
|
<label class="form-label">用户名</label>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<input
|
|
|
|
|
|
v-model="state.username"
|
|
|
|
|
|
type="text"
|
2026-03-07 00:41:05 +08:00
|
|
|
|
class="form-input"
|
2026-03-04 00:47:41 +08:00
|
|
|
|
placeholder="请输入用户名"
|
|
|
|
|
|
autocomplete="username"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="form-group">
|
2026-03-07 00:41:05 +08:00
|
|
|
|
<label class="form-label">密码</label>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<input
|
|
|
|
|
|
v-model="state.password"
|
|
|
|
|
|
type="password"
|
2026-03-07 00:41:05 +08:00
|
|
|
|
class="form-input"
|
2026-03-04 00:47:41 +08:00
|
|
|
|
placeholder="请输入密码"
|
2026-03-04 01:08:00 +08:00
|
|
|
|
autocomplete="current-password"
|
2026-03-04 00:47:41 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-07 00:41:05 +08:00
|
|
|
|
<div v-if="state.error" class="form-error">{{ state.error }}</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
2026-03-07 00:41:05 +08:00
|
|
|
|
<button type="submit" class="btn btn-primary btn-lg w-full" :disabled="state.loading">
|
2026-03-04 01:08:00 +08:00
|
|
|
|
{{ state.loading ? '登录中...' : '登录' }}
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
2026-03-07 00:41:05 +08:00
|
|
|
|
<div class="login-footer" style="margin-top: var(--space-6); text-align: center;">
|
|
|
|
|
|
<p style="font-size: var(--text-sm); color: var(--text-tertiary);">如需开通账号,请联系管理员</p>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const HomeView = {
|
|
|
|
|
|
setup() {
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const state = reactive({
|
|
|
|
|
|
stats: null,
|
|
|
|
|
|
loading: true
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const loadStats = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [inventoryStats, health] = await Promise.all([
|
2026-03-15 11:39:28 +08:00
|
|
|
|
apiRequest('/api/dashboard').catch(() => null),
|
2026-03-04 00:47:41 +08:00
|
|
|
|
apiRequest('/health').catch(() => null)
|
|
|
|
|
|
]);
|
|
|
|
|
|
state.stats = { inventory: inventoryStats, health };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载统计数据');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
if (!appState.user) {
|
|
|
|
|
|
router.push('/login');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
loadStats();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return { state, formatNumber, formatCurrency, appState };
|
|
|
|
|
|
},
|
|
|
|
|
|
template: `
|
|
|
|
|
|
<div class="page-container">
|
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
|
<h1>欢迎回来,{{ appState.user?.full_name || appState.user?.username }}</h1>
|
|
|
|
|
|
<p>系统概览</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="state.loading" class="loading-state">
|
|
|
|
|
|
<div class="spinner"></div>
|
2026-03-15 11:39:28 +08:00
|
|
|
|
<p>正在加载数据...</p>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else class="dashboard-grid">
|
|
|
|
|
|
<div class="stat-card" @click="$router.push('/inventory')">
|
|
|
|
|
|
<div class="stat-icon">📦</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ state.stats?.inventory?.product_count || 0 }}</div>
|
|
|
|
|
|
<div class="stat-label">产品数量</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="stat-card" @click="$router.push('/inventory')">
|
|
|
|
|
|
<div class="stat-icon">📊</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ state.stats?.inventory?.total_stock || 0 }}</div>
|
|
|
|
|
|
<div class="stat-label">库存总量</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="stat-card" @click="$router.push('/inventory')">
|
|
|
|
|
|
<div class="stat-icon">💰</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ formatCurrency(state.stats?.inventory?.total_value || 0) }}</div>
|
|
|
|
|
|
<div class="stat-label">库存价值</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="stat-card" @click="$router.push('/moldinsight')">
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<div class="stat-icon">⚙️</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ state.stats?.health?.total_tasks || 0 }}</div>
|
|
|
|
|
|
<div class="stat-label">分析任务</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="stat-card" @click="$router.push('/inventory')">
|
|
|
|
|
|
<div class="stat-icon">🏭</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ state.stats?.inventory?.supplier_count || 0 }}</div>
|
|
|
|
|
|
<div class="stat-label">供应商</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="stat-card" @click="$router.push('/inventory')">
|
|
|
|
|
|
<div class="stat-icon">👥</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ state.stats?.inventory?.customer_count || 0 }}</div>
|
|
|
|
|
|
<div class="stat-label">客户</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="state.stats?.inventory?.low_stock_products?.length" class="section">
|
|
|
|
|
|
<h2 class="section-title">低库存预警</h2>
|
|
|
|
|
|
<div class="table-container">
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>SKU</th>
|
|
|
|
|
|
<th>产品名称</th>
|
|
|
|
|
|
<th>当前库存</th>
|
|
|
|
|
|
<th>最低库存</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="item in state.stats.inventory.low_stock_products" :key="item.id">
|
|
|
|
|
|
<td>{{ item.sku }}</td>
|
|
|
|
|
|
<td>{{ item.name }}</td>
|
|
|
|
|
|
<td class="text-warning">{{ item.quantity }}</td>
|
|
|
|
|
|
<td>{{ item.min_stock }}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="quick-actions">
|
|
|
|
|
|
<h2 class="section-title">快速操作</h2>
|
|
|
|
|
|
<div class="action-grid">
|
|
|
|
|
|
<button class="action-card" @click="$router.push('/moldinsight')">
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<span class="action-icon">⚙️</span>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<span class="action-label">模具分析</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="action-card" @click="$router.push('/inventory')">
|
|
|
|
|
|
<span class="action-icon">📦</span>
|
|
|
|
|
|
<span class="action-label">库存管理</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="action-card" @click="$router.push('/inventory')">
|
|
|
|
|
|
<span class="action-icon">📥</span>
|
|
|
|
|
|
<span class="action-label">采购入库</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="action-card" @click="$router.push('/inventory')">
|
|
|
|
|
|
<span class="action-icon">📤</span>
|
|
|
|
|
|
<span class="action-label">销售出库</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const UsersView = {
|
2026-02-17 00:12:36 +08:00
|
|
|
|
setup() {
|
|
|
|
|
|
const router = useRouter();
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const state = reactive({
|
|
|
|
|
|
users: [],
|
2026-03-04 01:08:00 +08:00
|
|
|
|
roles: [],
|
|
|
|
|
|
loading: true,
|
|
|
|
|
|
showUserModal: false,
|
|
|
|
|
|
editingUser: null,
|
|
|
|
|
|
userForm: {
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
full_name: '',
|
|
|
|
|
|
role_ids: []
|
|
|
|
|
|
}
|
2026-03-04 00:47:41 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const loadUsers = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
state.users = await apiRequest('/api/auth/users');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载用户列表');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
2026-03-04 01:08:00 +08:00
|
|
|
|
const loadRoles = async () => {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
try {
|
2026-03-04 01:08:00 +08:00
|
|
|
|
state.roles = await apiRequest('/api/auth/roles');
|
2026-03-04 00:47:41 +08:00
|
|
|
|
} catch (e) {
|
2026-03-04 01:08:00 +08:00
|
|
|
|
handleApiError(e, '加载角色列表');
|
2026-03-04 00:47:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 01:08:00 +08:00
|
|
|
|
const openUserModal = (user = null) => {
|
|
|
|
|
|
state.editingUser = user;
|
|
|
|
|
|
if (user) {
|
|
|
|
|
|
state.userForm = {
|
|
|
|
|
|
username: user.username,
|
|
|
|
|
|
email: user.email,
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
full_name: user.full_name || '',
|
|
|
|
|
|
role_ids: user.roles.map(r => {
|
|
|
|
|
|
const role = state.roles.find(role => role.code === r);
|
|
|
|
|
|
return role ? role.id : null;
|
|
|
|
|
|
}).filter(id => id !== null)
|
|
|
|
|
|
};
|
|
|
|
|
|
} else {
|
|
|
|
|
|
state.userForm = {
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
full_name: '',
|
|
|
|
|
|
role_ids: []
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
state.showUserModal = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const saveUser = async () => {
|
|
|
|
|
|
if (!state.userForm.username || !state.userForm.email) {
|
|
|
|
|
|
addNotification('请填写用户名和邮箱', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!state.editingUser && !state.userForm.password) {
|
|
|
|
|
|
addNotification('请填写密码', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
try {
|
2026-03-04 01:08:00 +08:00
|
|
|
|
if (state.editingUser) {
|
|
|
|
|
|
await apiRequest(`/api/auth/users/${state.editingUser.id}`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
email: state.userForm.email,
|
|
|
|
|
|
full_name: state.userForm.full_name || null,
|
|
|
|
|
|
role_ids: state.userForm.role_ids
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
addNotification('用户更新成功', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await apiRequest('/api/auth/users', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify(state.userForm)
|
|
|
|
|
|
});
|
|
|
|
|
|
addNotification('用户创建成功', 'success');
|
|
|
|
|
|
}
|
|
|
|
|
|
state.showUserModal = false;
|
|
|
|
|
|
loadUsers();
|
2026-03-04 00:47:41 +08:00
|
|
|
|
} catch (e) {
|
2026-03-04 01:08:00 +08:00
|
|
|
|
handleApiError(e, '保存用户');
|
2026-03-04 00:47:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 01:08:00 +08:00
|
|
|
|
const deleteUser = async (user) => {
|
|
|
|
|
|
if (!confirm(`确定要删除用户 ${user.username} 吗?`)) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiRequest(`/api/auth/users/${user.id}`, { method: 'DELETE' });
|
|
|
|
|
|
addNotification('用户已删除', 'success');
|
|
|
|
|
|
loadUsers();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '删除用户');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resetPassword = async (user) => {
|
|
|
|
|
|
const newPassword = prompt(`请输入 ${user.username} 的新密码:`);
|
|
|
|
|
|
if (!newPassword || newPassword.length < 6) {
|
|
|
|
|
|
addNotification('密码长度至少6位', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiRequest(`/api/auth/users/${user.id}/reset-password`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
body: JSON.stringify(newPassword)
|
|
|
|
|
|
});
|
|
|
|
|
|
addNotification('密码已重置', 'success');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '重置密码');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
if (!appState.user?.is_superuser) {
|
|
|
|
|
|
router.push('/');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-04 01:08:00 +08:00
|
|
|
|
await loadRoles();
|
2026-03-04 00:47:41 +08:00
|
|
|
|
loadUsers();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-04 01:08:00 +08:00
|
|
|
|
return { state, appState, openUserModal, saveUser, deleteUser, resetPassword, formatDateTime };
|
2026-03-04 00:47:41 +08:00
|
|
|
|
},
|
|
|
|
|
|
template: `
|
|
|
|
|
|
<div class="page-container">
|
|
|
|
|
|
<div class="page-header">
|
2026-03-04 01:08:00 +08:00
|
|
|
|
<div>
|
|
|
|
|
|
<h1>用户管理</h1>
|
|
|
|
|
|
<p>管理系统用户和权限</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button class="btn-primary" @click="openUserModal()">+ 添加用户</button>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="state.loading" class="loading-state">
|
|
|
|
|
|
<div class="spinner"></div>
|
|
|
|
|
|
<span>加载中...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else class="table-container">
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>用户名</th>
|
|
|
|
|
|
<th>邮箱</th>
|
|
|
|
|
|
<th>姓名</th>
|
|
|
|
|
|
<th>状态</th>
|
|
|
|
|
|
<th>角色</th>
|
|
|
|
|
|
<th>注册时间</th>
|
|
|
|
|
|
<th>操作</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="user in state.users" :key="user.id">
|
|
|
|
|
|
<td>{{ user.username }}</td>
|
|
|
|
|
|
<td>{{ user.email }}</td>
|
|
|
|
|
|
<td>{{ user.full_name || '-' }}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<span :class="['badge', user.is_active ? 'badge-success' : 'badge-error']">
|
|
|
|
|
|
{{ user.is_active ? '正常' : '禁用' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
2026-03-04 01:08:00 +08:00
|
|
|
|
<span v-for="role in user.roles" :key="role" class="badge badge-info" style="margin-right: 4px;">
|
|
|
|
|
|
{{ role }}
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>{{ formatDateTime(user.created_at) }}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div class="action-buttons">
|
2026-03-04 01:08:00 +08:00
|
|
|
|
<button class="btn-sm btn-primary" @click="openUserModal(user)">编辑</button>
|
|
|
|
|
|
<button class="btn-sm btn-warning" @click="resetPassword(user)">重置密码</button>
|
|
|
|
|
|
<button v-if="user.id !== appState.user?.id" class="btn-sm btn-error" @click="deleteUser(user)">删除</button>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-03-04 01:08:00 +08:00
|
|
|
|
|
|
|
|
|
|
<div v-if="state.showUserModal" class="modal-overlay" @click.self="state.showUserModal = false">
|
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
|
<h2>{{ state.editingUser ? '编辑用户' : '添加用户' }}</h2>
|
|
|
|
|
|
<button class="modal-close" @click="state.showUserModal = false">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label>用户名</label>
|
|
|
|
|
|
<input v-model="state.userForm.username" type="text" :disabled="!!state.editingUser" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label>邮箱</label>
|
|
|
|
|
|
<input v-model="state.userForm.email" type="email" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group" v-if="!state.editingUser">
|
|
|
|
|
|
<label>密码</label>
|
|
|
|
|
|
<input v-model="state.userForm.password" type="password" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label>姓名</label>
|
|
|
|
|
|
<input v-model="state.userForm.full_name" type="text" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label>角色</label>
|
|
|
|
|
|
<div class="checkbox-group">
|
|
|
|
|
|
<label v-for="role in state.roles" :key="role.id" class="checkbox-label">
|
|
|
|
|
|
<input type="checkbox" :value="role.id" v-model="state.userForm.role_ids" />
|
|
|
|
|
|
{{ role.name }}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
|
<button class="btn-secondary" @click="state.showUserModal = false">取消</button>
|
|
|
|
|
|
<button class="btn-primary" @click="saveUser">保存</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
`
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const MoldInsightView = {
|
|
|
|
|
|
setup() {
|
|
|
|
|
|
const router = useRouter();
|
2026-02-17 00:42:55 +08:00
|
|
|
|
const state = reactive({
|
|
|
|
|
|
selectedFile: null,
|
2026-03-14 01:29:40 +08:00
|
|
|
|
selectedMaterial: 'ABS',
|
|
|
|
|
|
moldParams: {
|
|
|
|
|
|
draftAngle: 2.0,
|
|
|
|
|
|
shrinkageRate: 0.5,
|
|
|
|
|
|
partingPrecision: 0.1,
|
|
|
|
|
|
cavityMatch: 95
|
|
|
|
|
|
},
|
2026-02-17 00:42:55 +08:00
|
|
|
|
uploading: false,
|
|
|
|
|
|
error: "",
|
|
|
|
|
|
currentTask: null,
|
|
|
|
|
|
polling: false,
|
2026-03-03 23:57:04 +08:00
|
|
|
|
dragOver: false,
|
2026-03-04 00:47:41 +08:00
|
|
|
|
progress: 0,
|
2026-03-07 03:04:15 +08:00
|
|
|
|
history: null,
|
2026-03-08 00:51:39 +08:00
|
|
|
|
expandedFiles: {}
|
2026-02-17 00:42:55 +08:00
|
|
|
|
});
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const loadHistory = async () => {
|
|
|
|
|
|
try {
|
2026-03-04 22:53:31 +08:00
|
|
|
|
state.history = await apiRequest('/api/history');
|
2026-03-04 00:47:41 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('加载历史记录失败:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
2026-03-08 00:51:39 +08:00
|
|
|
|
const toggleFileHistory = async (filename) => {
|
|
|
|
|
|
if (state.expandedFiles[filename]) {
|
|
|
|
|
|
state.expandedFiles[filename] = null;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const records = await apiRequest(`/api/history/${encodeURIComponent(filename)}`);
|
|
|
|
|
|
state.expandedFiles[filename] = records;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载文件历史');
|
|
|
|
|
|
}
|
2026-03-07 03:04:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const viewResult = (record) => {
|
2026-03-08 00:51:39 +08:00
|
|
|
|
router.push(`/moldinsight/result/${record.task_id}`);
|
2026-03-07 03:04:15 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-17 00:12:36 +08:00
|
|
|
|
const handleFileChange = (event) => {
|
|
|
|
|
|
const file = event.target.files[0];
|
|
|
|
|
|
if (!file) return;
|
2026-02-17 00:42:55 +08:00
|
|
|
|
validateAndSelectFile(file);
|
|
|
|
|
|
};
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
2026-02-17 00:42:55 +08:00
|
|
|
|
const validateAndSelectFile = (file) => {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
if (!file.name.toLowerCase().endsWith(".stp") && !file.name.toLowerCase().endsWith(".step")) {
|
2026-02-17 00:42:55 +08:00
|
|
|
|
state.error = "请选择 STP 或 STEP 格式文件";
|
|
|
|
|
|
state.selectedFile = null;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (file.size > 100 * 1024 * 1024) {
|
2026-02-17 00:42:55 +08:00
|
|
|
|
state.error = "文件大小不能超过 100MB";
|
|
|
|
|
|
state.selectedFile = null;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-17 00:42:55 +08:00
|
|
|
|
state.error = "";
|
|
|
|
|
|
state.selectedFile = file;
|
|
|
|
|
|
addNotification(`已选择文件: ${file.name}`, 'success');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDrop = (event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
state.dragOver = false;
|
|
|
|
|
|
const files = event.dataTransfer.files;
|
2026-03-04 00:47:41 +08:00
|
|
|
|
if (files.length > 0) validateAndSelectFile(files[0]);
|
2026-02-17 00:12:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const uploadFile = async () => {
|
2026-02-17 00:42:55 +08:00
|
|
|
|
if (!state.selectedFile) return;
|
|
|
|
|
|
state.uploading = true;
|
|
|
|
|
|
state.error = "";
|
2026-03-03 23:57:04 +08:00
|
|
|
|
state.progress = 0;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
|
|
|
|
|
const formData = new FormData();
|
2026-02-17 00:42:55 +08:00
|
|
|
|
formData.append("file", state.selectedFile);
|
2026-03-14 01:29:40 +08:00
|
|
|
|
formData.append("material", state.selectedMaterial);
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-04 22:53:31 +08:00
|
|
|
|
const res = await fetch("/api/upload", {
|
2026-02-17 00:12:36 +08:00
|
|
|
|
method: "POST",
|
2026-03-04 00:47:41 +08:00
|
|
|
|
headers: appState.token ? { 'Authorization': `Bearer ${appState.token}` } : {},
|
|
|
|
|
|
body: formData
|
2026-02-17 00:12:36 +08:00
|
|
|
|
});
|
2026-03-04 00:47:41 +08:00
|
|
|
|
if (!res.ok) throw new Error(`上传失败: ${res.status}`);
|
2026-02-17 00:12:36 +08:00
|
|
|
|
const data = await res.json();
|
2026-03-04 00:47:41 +08:00
|
|
|
|
state.currentTask = { task_id: data.task_id, status: "processing", filename: data.file_info?.filename };
|
|
|
|
|
|
addNotification('文件上传成功,开始分析...', 'success');
|
2026-02-17 00:12:36 +08:00
|
|
|
|
startPolling(data.task_id);
|
|
|
|
|
|
} catch (e) {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
state.error = handleApiError(e, '文件上传');
|
2026-02-17 00:12:36 +08:00
|
|
|
|
} finally {
|
2026-02-17 00:42:55 +08:00
|
|
|
|
state.uploading = false;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const startPolling = async (taskId) => {
|
2026-03-14 02:16:59 +08:00
|
|
|
|
console.log('[Polling] 开始轮询任务:', taskId);
|
2026-02-17 00:42:55 +08:00
|
|
|
|
state.polling = true;
|
2026-03-03 23:57:04 +08:00
|
|
|
|
state.progress = 10;
|
2026-02-17 00:47:39 +08:00
|
|
|
|
let pollCount = 0;
|
|
|
|
|
|
|
2026-02-17 00:12:36 +08:00
|
|
|
|
const poll = async () => {
|
|
|
|
|
|
try {
|
2026-02-17 00:47:39 +08:00
|
|
|
|
pollCount++;
|
2026-03-03 23:57:04 +08:00
|
|
|
|
state.progress = Math.min(90, 10 + pollCount * 0.5);
|
|
|
|
|
|
|
2026-03-04 22:53:31 +08:00
|
|
|
|
const task = await apiRequest(`/api/status/${taskId}`, { method: 'POST' });
|
2026-02-17 00:42:55 +08:00
|
|
|
|
state.currentTask = task;
|
2026-03-14 02:16:59 +08:00
|
|
|
|
state.task = task;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
|
|
|
|
|
if (task.status === "completed") {
|
2026-02-17 00:42:55 +08:00
|
|
|
|
state.polling = false;
|
2026-03-03 23:57:04 +08:00
|
|
|
|
state.progress = 100;
|
2026-03-04 00:47:41 +08:00
|
|
|
|
addNotification('分析完成', 'success');
|
2026-03-07 03:04:15 +08:00
|
|
|
|
loadHistory();
|
2026-03-04 00:47:41 +08:00
|
|
|
|
router.push(`/moldinsight/result/${taskId}`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (task.status === "failed") {
|
2026-02-17 00:47:39 +08:00
|
|
|
|
state.polling = false;
|
2026-03-04 00:47:41 +08:00
|
|
|
|
state.error = task.error || "分析失败";
|
|
|
|
|
|
addNotification('分析失败', 'error');
|
|
|
|
|
|
return;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
}
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
|
|
|
|
|
if (pollCount < 300) setTimeout(poll, 2000);
|
2026-02-17 00:12:36 +08:00
|
|
|
|
} catch (e) {
|
2026-02-17 00:42:55 +08:00
|
|
|
|
state.polling = false;
|
2026-03-04 00:47:41 +08:00
|
|
|
|
state.error = handleApiError(e, '轮询状态');
|
2026-02-17 00:12:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
poll();
|
2026-02-17 00:42:55 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-17 00:12:36 +08:00
|
|
|
|
onMounted(() => {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
if (!appState.user) {
|
|
|
|
|
|
router.push('/login');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
loadHistory();
|
2026-02-17 00:12:36 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-15 11:39:28 +08:00
|
|
|
|
const isFoamMaterial = (material) => {
|
|
|
|
|
|
const foamMaterials = ['AlSi10Mg', 'AlSi12', 'Pure Al Foam', 'AlSi7Mg'];
|
|
|
|
|
|
return foamMaterials.includes(material);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
return {
|
|
|
|
|
|
state,
|
|
|
|
|
|
handleFileChange,
|
|
|
|
|
|
handleDrop,
|
|
|
|
|
|
uploadFile,
|
|
|
|
|
|
formatFileSize,
|
2026-03-07 03:04:15 +08:00
|
|
|
|
formatDateTime,
|
2026-03-08 01:21:36 +08:00
|
|
|
|
formatNumber,
|
2026-03-08 00:51:39 +08:00
|
|
|
|
toggleFileHistory,
|
2026-03-15 11:39:28 +08:00
|
|
|
|
viewResult,
|
|
|
|
|
|
isFoamMaterial
|
2026-02-17 00:12:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
template: `
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="page-container">
|
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
|
<h1>MoldInsight</h1>
|
|
|
|
|
|
<p>STP 模具几何分析</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="upload-section">
|
|
|
|
|
|
<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">点击选择或拖拽文件</span>
|
|
|
|
|
|
<span class="upload-hint">支持 .stp, .step 格式,最大 100MB</span>
|
|
|
|
|
|
</div>
|
2026-03-03 23:57:04 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-03-14 01:29:40 +08:00
|
|
|
|
<button class="btn-clear" @click="state.selectedFile = null" title="清除文件">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 材料选择面板 -->
|
|
|
|
|
|
<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="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>
|
|
|
|
|
|
</optgroup>
|
|
|
|
|
|
<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>
|
2026-03-03 23:57:04 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
|
|
|
|
|
<div v-if="state.error" class="error-message">{{ state.error }}</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="state.selectedFile"
|
|
|
|
|
|
class="btn-primary"
|
|
|
|
|
|
@click="uploadFile"
|
|
|
|
|
|
:disabled="state.uploading || state.polling"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ state.uploading ? '上传中...' : state.polling ? '分析中...' : '开始分析' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="state.polling" class="progress-bar">
|
|
|
|
|
|
<div class="progress-fill" :style="{ width: state.progress + '%' }"></div>
|
2026-03-03 23:57:04 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
|
|
|
|
|
<div v-if="state.history?.files?.length" class="section">
|
2026-03-07 03:04:15 +08:00
|
|
|
|
<h2 class="section-title">分析历史</h2>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="table-container">
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>文件名</th>
|
2026-03-07 03:04:15 +08:00
|
|
|
|
<th>上传次数</th>
|
|
|
|
|
|
<th>文件大小</th>
|
|
|
|
|
|
<th>最新状态</th>
|
|
|
|
|
|
<th>最新分析时间</th>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<th>操作</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
2026-03-08 00:51:39 +08:00
|
|
|
|
<template v-for="file in state.history.files" :key="file.filename">
|
2026-03-08 01:21:36 +08:00
|
|
|
|
<tr>
|
|
|
|
|
|
<td>{{ file.filename }}</td>
|
2026-03-08 00:51:39 +08:00
|
|
|
|
<td>
|
|
|
|
|
|
<span class="badge badge-info">{{ file.upload_count }} 次</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>{{ formatFileSize(file.file_size) }}</td>
|
2026-03-07 03:04:15 +08:00
|
|
|
|
<td>
|
2026-03-08 00:51:39 +08:00
|
|
|
|
<span :class="['badge', file.latest_status === 'completed' ? 'badge-success' : file.latest_status === 'failed' ? 'badge-error' : 'badge-warning']">
|
|
|
|
|
|
{{ file.latest_status }}
|
2026-03-07 03:04:15 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
2026-03-08 00:51:39 +08:00
|
|
|
|
<td>{{ formatDateTime(file.latest_upload_time) }}</td>
|
2026-03-07 03:04:15 +08:00
|
|
|
|
<td>
|
2026-03-08 01:21:36 +08:00
|
|
|
|
<div class="action-buttons">
|
2026-03-08 00:51:39 +08:00
|
|
|
|
<button v-if="file.latest_status === 'completed'" class="btn-sm btn-primary" @click="viewResult({task_id: file.latest_task_id})">
|
|
|
|
|
|
查看最新
|
|
|
|
|
|
</button>
|
2026-03-08 02:22:20 +08:00
|
|
|
|
<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>
|
2026-03-08 01:21:36 +08:00
|
|
|
|
</button>
|
2026-03-08 00:51:39 +08:00
|
|
|
|
</div>
|
2026-03-07 03:04:15 +08:00
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
2026-03-08 00:51:39 +08:00
|
|
|
|
<tr v-if="state.expandedFiles[file.filename]" class="history-detail-row">
|
|
|
|
|
|
<td colspan="6">
|
2026-03-08 01:21:36 +08:00
|
|
|
|
<div class="history-dropdown">
|
2026-03-08 00:51:39 +08:00
|
|
|
|
<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>
|
2026-03-07 03:04:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
`
|
2026-02-17 00:12:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const ResultView = {
|
2026-02-17 00:12:36 +08:00
|
|
|
|
setup() {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const route = useRoute();
|
2026-02-17 00:12:36 +08:00
|
|
|
|
const router = useRouter();
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const state = reactive({
|
|
|
|
|
|
task: null,
|
|
|
|
|
|
loading: true,
|
|
|
|
|
|
error: ''
|
|
|
|
|
|
});
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const loadTask = async () => {
|
2026-03-14 02:39:01 +08:00
|
|
|
|
console.log('[ResultView] loadTask 开始, taskId:', route.params.taskId);
|
2026-02-17 00:12:36 +08:00
|
|
|
|
try {
|
2026-03-04 22:53:31 +08:00
|
|
|
|
state.task = await apiRequest(`/api/status/${route.params.taskId}`, { method: 'POST' });
|
2026-03-04 23:19:09 +08:00
|
|
|
|
console.log('任务数据:', state.task);
|
2026-03-04 23:29:59 +08:00
|
|
|
|
console.log('key_info:', state.task.key_info);
|
|
|
|
|
|
console.log('cavity_data:', state.task.cavity_data);
|
2026-03-07 00:40:57 +08:00
|
|
|
|
console.log('analysis_result:', state.task.analysis_result);
|
|
|
|
|
|
console.log('geometry_data:', state.task.geometry_data);
|
2026-02-17 00:12:36 +08:00
|
|
|
|
} catch (e) {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
state.error = handleApiError(e, '加载任务详情');
|
2026-02-17 00:12:36 +08:00
|
|
|
|
} finally {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
state.loading = false;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
if (!appState.user) {
|
|
|
|
|
|
router.push('/login');
|
|
|
|
|
|
return;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
}
|
2026-03-04 00:47:41 +08:00
|
|
|
|
loadTask();
|
|
|
|
|
|
});
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
2026-03-07 01:01:15 +08:00
|
|
|
|
const getPriorityText = (priority) => {
|
|
|
|
|
|
const priorityMap = {
|
|
|
|
|
|
'critical': '紧急',
|
|
|
|
|
|
'high': '高',
|
|
|
|
|
|
'medium': '中',
|
|
|
|
|
|
'low': '低'
|
|
|
|
|
|
};
|
|
|
|
|
|
return priorityMap[priority] || priority;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-14 02:39:01 +08:00
|
|
|
|
const isFoamMaterial = (material) => {
|
|
|
|
|
|
return material === 'aluminum_foam';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-14 01:29:40 +08:00
|
|
|
|
return { state, formatFileSize, formatDateTime, formatNumber, getPriorityText, isFoamMaterial };
|
2026-02-17 00:12:36 +08:00
|
|
|
|
},
|
|
|
|
|
|
template: `
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="page-container">
|
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
|
<button class="btn-back" @click="$router.back()">← 返回</button>
|
|
|
|
|
|
<h1>分析结果</h1>
|
2026-03-04 00:10:05 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div v-if="state.loading" class="loading-state">
|
|
|
|
|
|
<div class="spinner"></div>
|
|
|
|
|
|
<span>加载中...</span>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
|
|
|
|
|
<div v-else-if="state.error" class="error-state">
|
|
|
|
|
|
<p>{{ state.error }}</p>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
|
|
|
|
|
<div v-else-if="state.task" class="result-container">
|
|
|
|
|
|
<div class="result-header">
|
|
|
|
|
|
<h2>{{ state.task.filename }}</h2>
|
|
|
|
|
|
<span :class="['badge', state.task.status === 'completed' ? 'badge-success' : 'badge-error']">
|
|
|
|
|
|
{{ state.task.status }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="result-grid">
|
|
|
|
|
|
<div class="result-card">
|
|
|
|
|
|
<h3>文件信息</h3>
|
|
|
|
|
|
<div class="info-list">
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">文件大小</span>
|
|
|
|
|
|
<span class="info-value">{{ formatFileSize(state.task.file_size) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">分析时间</span>
|
|
|
|
|
|
<span class="info-value">{{ formatDateTime(state.task.completed_at) }}</span>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">状态</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.status }}</span>
|
|
|
|
|
|
</div>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
2026-03-04 23:29:59 +08:00
|
|
|
|
<div class="result-card">
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<h3>几何数据</h3>
|
|
|
|
|
|
<div class="info-list">
|
2026-03-04 23:19:09 +08:00
|
|
|
|
<div class="info-item" v-if="state.task.mesh_summary">
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<span class="info-label">顶点数</span>
|
2026-03-04 23:19:09 +08:00
|
|
|
|
<span class="info-value">{{ formatNumber(state.task.mesh_summary.vertex_count) }}</span>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-04 23:19:09 +08:00
|
|
|
|
<div class="info-item" v-if="state.task.mesh_summary">
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<span class="info-label">面数</span>
|
2026-03-04 23:19:09 +08:00
|
|
|
|
<span class="info-value">{{ formatNumber(state.task.mesh_summary.face_count) }}</span>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
<div class="info-item" v-if="state.task.mesh_summary || state.task.geometry_data">
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<span class="info-label">边数</span>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
<span class="info-value">{{ formatNumber(state.task.geometry_data?.topology?.edge_count || state.task.geometry_data?.topology?.edges || state.task.mesh_summary?.edge_count || 'N/A') }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item" v-if="state.task.geometry_data">
|
|
|
|
|
|
<span class="info-label">体积</span>
|
|
|
|
|
|
<span class="info-value">{{ formatNumber(state.task.geometry_data.volume) }} mm³</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item" v-if="state.task.geometry_data">
|
|
|
|
|
|
<span class="info-label">表面积</span>
|
|
|
|
|
|
<span class="info-value">{{ formatNumber(state.task.geometry_data.surface_area) }} mm²</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="state.task.cavity_data || state.task.key_info" class="viewer-section">
|
|
|
|
|
|
<h3>模具信息</h3>
|
|
|
|
|
|
<div class="result-grid">
|
|
|
|
|
|
<div class="result-card">
|
|
|
|
|
|
<h4>基本信息</h4>
|
|
|
|
|
|
<div class="info-list">
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">零件名称</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.cavity_data?.metadata?.part_name || state.task.key_info?.metadata?.part_name || 'N/A' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">材料</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.cavity_data?.metadata?.material || state.task.key_info?.metadata?.material || 'N/A' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="result-card" v-if="state.task.cavity_data?.mold_cavities || state.task.key_info?.mold_cavities">
|
|
|
|
|
|
<h4>型腔信息</h4>
|
|
|
|
|
|
<div class="info-list">
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">型腔数量</span>
|
|
|
|
|
|
<span class="info-value">{{ Object.keys(state.task.cavity_data?.mold_cavities || state.task.key_info?.mold_cavities || {}).length }}</span>
|
|
|
|
|
|
</div>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="state.task.html_file" class="viewer-section">
|
|
|
|
|
|
<h3>3D 预览</h3>
|
|
|
|
|
|
<iframe :src="state.task.html_file" class="viewer-frame"></iframe>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
|
|
|
|
|
|
<div v-if="state.task.analysis_result" class="viewer-section">
|
|
|
|
|
|
<h3>分析结果详情</h3>
|
|
|
|
|
|
|
2026-03-08 03:11:28 +08:00
|
|
|
|
<!-- 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>
|
2026-03-08 03:43:40 +08:00
|
|
|
|
<div class="verification-details" v-if="state.task.verification.comparison && (state.task.verification.comparison.volume || state.task.verification.comparison.surface_area)">
|
2026-03-08 03:11:28 +08:00
|
|
|
|
<div class="comparison-grid">
|
2026-03-08 03:43:40 +08:00
|
|
|
|
<div class="comparison-item" v-if="state.task.verification.comparison.volume">
|
2026-03-08 03:11:28 +08:00
|
|
|
|
<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>
|
2026-03-08 03:43:40 +08:00
|
|
|
|
<div class="comparison-item" v-if="state.task.verification.comparison.surface_area">
|
2026-03-08 03:11:28 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-03-06 00:52:06 +08:00
|
|
|
|
<!-- 分析摘要 -->
|
|
|
|
|
|
<div class="analysis-summary" v-if="state.task.analysis_result.analysis_summary">
|
|
|
|
|
|
<div class="summary-header">
|
|
|
|
|
|
<h4>分析摘要</h4>
|
|
|
|
|
|
<span class="summary-icon">📋</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="summary-content">
|
|
|
|
|
|
{{ state.task.analysis_result.analysis_summary }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-04 23:29:59 +08:00
|
|
|
|
<div class="result-grid">
|
|
|
|
|
|
<div class="result-card full-width">
|
|
|
|
|
|
<h4>1. 产品特征识别</h4>
|
|
|
|
|
|
<div class="info-list">
|
2026-03-04 23:41:50 +08:00
|
|
|
|
<div class="info-item">
|
2026-03-04 23:29:59 +08:00
|
|
|
|
<span class="info-label">整体轮廓和尺寸比例</span>
|
2026-03-07 00:56:51 +08:00
|
|
|
|
<span class="info-value">{{ state.task.analysis_result?.geometry_data?.bounding_box?.dimensions ?
|
|
|
|
|
|
(state.task.analysis_result.geometry_data.bounding_box.dimensions[0]?.toFixed(1) || 0) + ' × ' +
|
|
|
|
|
|
(state.task.analysis_result.geometry_data.bounding_box.dimensions[1]?.toFixed(1) || 0) + ' × ' +
|
|
|
|
|
|
(state.task.analysis_result.geometry_data.bounding_box.dimensions[2]?.toFixed(1) || 0) + ' mm' : 'N/A' }}</span>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">壁厚分布</span>
|
2026-03-06 00:52:06 +08:00
|
|
|
|
<span class="info-value">{{ state.task.analysis_result.detected_features?.find(f => f.feature_type === 'wall_thickness')?.description || '待分析' }}</span>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">加强筋位置和密度</span>
|
2026-03-06 00:52:06 +08:00
|
|
|
|
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === 'rib').length || 0 }} 个加强筋</span>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">孔洞和凹槽位置</span>
|
2026-03-06 00:52:06 +08:00
|
|
|
|
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === 'hole' || f.feature_type === 'pocket').length || 0 }} 个孔洞/凹槽</span>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">倒扣区域检测</span>
|
2026-03-06 00:52:06 +08:00
|
|
|
|
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === 'undercut').length || 0 }} 个倒扣区域</span>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">对称性分析</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.analysis_result.geometry_data?.symmetry || '非对称' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">重心位置</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.analysis_result.geometry_data?.center_of_mass ?
|
2026-03-04 23:41:50 +08:00
|
|
|
|
'(' + (state.task.analysis_result.geometry_data.center_of_mass[0]?.toFixed(2) || 0) + ', ' +
|
|
|
|
|
|
(state.task.analysis_result.geometry_data.center_of_mass[1]?.toFixed(2) || 0) + ', ' +
|
|
|
|
|
|
(state.task.analysis_result.geometry_data.center_of_mass[2]?.toFixed(2) || 0) + ') mm' : 'N/A' }}</span>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="result-card full-width">
|
|
|
|
|
|
<h4>2. 泡沫包装设计决策</h4>
|
|
|
|
|
|
<div class="info-list">
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">泡沫厚度建议</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.analysis_result.design_recommendations?.find(r => r.priority === 'high')?.recommendation || '根据产品重量和脆弱程度自动计算' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">加强筋布局</span>
|
|
|
|
|
|
<span class="info-value">基于产品薄弱区域自动布置</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">取手槽位置</span>
|
|
|
|
|
|
<span class="info-value">基于重心位置:{{ state.task.analysis_result.geometry_data?.center_of_mass ? '自动优化' : '手动设置' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">通风孔位置</span>
|
|
|
|
|
|
<span class="info-value">防止真空吸附:建议在产品最大平面区域设置通风孔</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">定位结构设计</span>
|
2026-03-06 00:52:06 +08:00
|
|
|
|
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === '定位').length || 0 }} 个定位特征</span>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">分型面选择</span>
|
|
|
|
|
|
<span class="info-value">基于产品几何:自动推荐最优分型面</span>
|
|
|
|
|
|
</div>
|
2026-03-06 00:52:06 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 设计建议列表 -->
|
|
|
|
|
|
<div class="recommendations-section" v-if="state.task.analysis_result.design_recommendations?.length">
|
|
|
|
|
|
<h5>详细设计建议</h5>
|
|
|
|
|
|
<div class="recommendations-list">
|
|
|
|
|
|
<div v-for="rec in state.task.analysis_result.design_recommendations.sort((a, b) => {
|
|
|
|
|
|
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="recommendation-text">{{ rec.description }}</span>
|
|
|
|
|
|
<span class="recommendation-reason" v-if="rec.reason">({{ rec.reason }})</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="result-card full-width">
|
|
|
|
|
|
<h4>3. 模具工程决策</h4>
|
|
|
|
|
|
<div class="info-list">
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">型腔数量建议</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.cavity_data?.mold_cavities ? Object.keys(state.task.cavity_data.mold_cavities).length : 1 }} 腔</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">模架尺寸</span>
|
2026-03-06 00:52:06 +08:00
|
|
|
|
<span class="info-value">{{ state.task.key_info?.mold_parameters?.mold_size || '基于泡沫外形自动计算' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item" v-if="state.task.key_info?.geometric_characteristics">
|
|
|
|
|
|
<span class="info-label">预估锁模力</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.key_info.geometric_characteristics.estimated_clamping_force || '自动计算' }} 吨</span>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">顶出系统</span>
|
|
|
|
|
|
<span class="info-value">顶针顶出(自动布局)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">冷却水路设计</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.analysis_result.geometry_data?.volume > 1000000 ? '需要冷却水路' : '自然冷却' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="info-label">材料选择</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.analysis_result.quality_metrics?.volume ? '根据产量和精度自动推荐' : '7075 铝合金' }}</span>
|
|
|
|
|
|
</div>
|
2026-03-06 00:52:06 +08:00
|
|
|
|
<div class="info-item" v-if="state.task.analysis_result.quality_metrics">
|
|
|
|
|
|
<span class="info-label">体积利用率</span>
|
|
|
|
|
|
<span class="info-value">{{ (state.task.analysis_result.quality_metrics.volume_utilization * 100)?.toFixed(1) || 'N/A' }}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item" v-if="state.task.analysis_result.quality_metrics">
|
|
|
|
|
|
<span class="info-label">拓扑复杂度</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.analysis_result.quality_metrics.topology_complexity?.toFixed(2) || 'N/A' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item" v-if="state.task.analysis_result.quality_metrics">
|
|
|
|
|
|
<span class="info-label">壁厚均匀性</span>
|
|
|
|
|
|
<span class="info-value">{{ (state.task.analysis_result.quality_metrics.wall_uniformity * 100)?.toFixed(1) || 'N/A' }}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item" v-if="state.task.key_info?.quality_considerations">
|
|
|
|
|
|
<span class="info-label">翘曲风险</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.key_info.quality_considerations.warpage_risk || '低风险' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item" v-if="state.task.key_info?.quality_considerations">
|
|
|
|
|
|
<span class="info-label">潜在焊缝线</span>
|
|
|
|
|
|
<span class="info-value">{{ state.task.key_info.quality_considerations.potential_weld_lines || 0 }} 条</span>
|
|
|
|
|
|
</div>
|
2026-03-04 23:29:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
`
|
2026-02-17 00:12:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const InventoryView = {
|
2026-02-17 00:12:36 +08:00
|
|
|
|
setup() {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const router = useRouter();
|
2026-02-17 00:12:36 +08:00
|
|
|
|
const route = useRoute();
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const state = reactive({
|
|
|
|
|
|
activeTab: 'dashboard',
|
|
|
|
|
|
dashboard: null,
|
2026-03-15 15:47:43 +08:00
|
|
|
|
financeSummary: null,
|
2026-03-15 22:04:28 +08:00
|
|
|
|
financePeriod: {
|
|
|
|
|
|
year: new Date().getFullYear(),
|
|
|
|
|
|
quarter: ''
|
|
|
|
|
|
},
|
2026-03-15 15:47:43 +08:00
|
|
|
|
financeTransactions: [],
|
|
|
|
|
|
receivables: [],
|
|
|
|
|
|
payables: [],
|
2026-03-15 22:04:28 +08:00
|
|
|
|
customerFinanceStatement: [],
|
|
|
|
|
|
supplierFinanceStatement: [],
|
|
|
|
|
|
customerProductStatement: [],
|
|
|
|
|
|
supplierProductStatement: [],
|
2026-03-04 00:47:41 +08:00
|
|
|
|
products: [],
|
2026-03-15 22:50:38 +08:00
|
|
|
|
materials: [],
|
2026-03-17 22:20:56 +08:00
|
|
|
|
purchaseOrders: [],
|
|
|
|
|
|
purchaseWarehouseId: null,
|
|
|
|
|
|
purchaseReceiveItems: [],
|
2026-03-15 22:50:38 +08:00
|
|
|
|
productionOrders: [],
|
|
|
|
|
|
productionPlan: null,
|
|
|
|
|
|
productionWarehouseId: null,
|
2026-03-04 00:47:41 +08:00
|
|
|
|
suppliers: [],
|
|
|
|
|
|
customers: [],
|
|
|
|
|
|
warehouses: [],
|
|
|
|
|
|
inventory: [],
|
|
|
|
|
|
movements: [],
|
2026-03-15 13:50:23 +08:00
|
|
|
|
loading: false,
|
|
|
|
|
|
showModal: false,
|
|
|
|
|
|
modalType: '',
|
|
|
|
|
|
editingItem: null,
|
2026-03-15 22:50:38 +08:00
|
|
|
|
productBomItems: [],
|
2026-03-15 13:50:23 +08:00
|
|
|
|
form: {}
|
2026-03-04 00:47:41 +08:00
|
|
|
|
});
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
2026-03-15 22:30:41 +08:00
|
|
|
|
const getMovementTypeLabel = (movementType) => {
|
|
|
|
|
|
const movementLabelMap = {
|
|
|
|
|
|
in: '其他入库',
|
|
|
|
|
|
out: '其他出库',
|
|
|
|
|
|
adjust: '库存调整',
|
|
|
|
|
|
purchase_in: '采购入库',
|
|
|
|
|
|
return_from_production: '生产退料入库',
|
|
|
|
|
|
outsource_return: '外协回库',
|
|
|
|
|
|
finish_in: '完工入库',
|
|
|
|
|
|
issue_to_production: '生产领料出库',
|
|
|
|
|
|
outsource_send: '外协发料出库',
|
|
|
|
|
|
shipment_out: '销售出库',
|
|
|
|
|
|
scrap_out: '报废出库'
|
|
|
|
|
|
};
|
|
|
|
|
|
return movementLabelMap[movementType] || movementType;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getMovementBadgeClass = (movementType) => {
|
|
|
|
|
|
if (['purchase_in', 'return_from_production', 'outsource_return', 'finish_in', 'in'].includes(movementType)) {
|
|
|
|
|
|
return 'badge-success';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (['issue_to_production', 'outsource_send', 'shipment_out', 'scrap_out', 'out'].includes(movementType)) {
|
|
|
|
|
|
return 'badge-error';
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'badge-warning';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const loadDashboard = async () => {
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
try {
|
2026-03-15 11:39:28 +08:00
|
|
|
|
state.dashboard = await apiRequest('/api/dashboard');
|
2026-03-04 00:47:41 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载仪表盘');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-02-17 00:12:36 +08:00
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const loadProducts = async () => {
|
|
|
|
|
|
state.loading = true;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
try {
|
2026-03-15 22:55:55 +08:00
|
|
|
|
state.products = await apiRequest('/api/products?limit=100');
|
2026-03-04 00:47:41 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载产品');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 22:50:38 +08:00
|
|
|
|
const loadMaterials = async () => {
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
try {
|
2026-03-15 22:55:55 +08:00
|
|
|
|
state.materials = await apiRequest('/api/products?item_type=material&limit=100');
|
2026-03-15 22:50:38 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载物料');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 22:04:28 +08:00
|
|
|
|
const loadWarehouses = async () => {
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
state.warehouses = await apiRequest('/api/warehouses');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载仓库');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 22:13:51 +08:00
|
|
|
|
const ensureStockBaseData = async () => {
|
2026-03-15 22:50:38 +08:00
|
|
|
|
if (!state.materials.length) {
|
|
|
|
|
|
await loadMaterials();
|
2026-03-15 22:13:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (!state.warehouses.length) {
|
|
|
|
|
|
await loadWarehouses();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!state.warehouses.length) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiRequest('/api/warehouses', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
name: '默认仓库'
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
await loadWarehouses();
|
|
|
|
|
|
addNotification('已自动创建默认仓库', 'success');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '自动创建默认仓库');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const loadSuppliers = async () => {
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
try {
|
2026-03-15 11:39:28 +08:00
|
|
|
|
state.suppliers = await apiRequest('/api/suppliers');
|
2026-03-04 00:47:41 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载供应商');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 22:50:38 +08:00
|
|
|
|
const loadProductionOrders = async () => {
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [orders, warehouses] = await Promise.all([
|
|
|
|
|
|
apiRequest('/api/sales-orders?limit=100'),
|
|
|
|
|
|
apiRequest('/api/warehouses')
|
|
|
|
|
|
]);
|
|
|
|
|
|
state.productionOrders = orders || [];
|
|
|
|
|
|
state.warehouses = warehouses || [];
|
|
|
|
|
|
if (!state.productionWarehouseId) {
|
|
|
|
|
|
state.productionWarehouseId = state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载按单生产数据');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-17 22:20:56 +08:00
|
|
|
|
const loadPurchaseOrders = async () => {
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [orders, warehouses] = await Promise.all([
|
|
|
|
|
|
apiRequest('/api/purchase-orders?limit=100'),
|
|
|
|
|
|
apiRequest('/api/warehouses')
|
|
|
|
|
|
]);
|
|
|
|
|
|
state.purchaseOrders = orders || [];
|
|
|
|
|
|
state.warehouses = warehouses || [];
|
|
|
|
|
|
if (!state.purchaseWarehouseId) {
|
|
|
|
|
|
state.purchaseWarehouseId = state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载采购订单');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const loadCustomers = async () => {
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
try {
|
2026-03-15 11:39:28 +08:00
|
|
|
|
state.customers = await apiRequest('/api/customers');
|
2026-03-04 00:47:41 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载客户');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadInventory = async () => {
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
try {
|
2026-03-15 11:39:28 +08:00
|
|
|
|
state.inventory = await apiRequest('/api/inventory');
|
2026-02-17 00:12:36 +08:00
|
|
|
|
} catch (e) {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
handleApiError(e, '加载库存');
|
2026-02-17 00:12:36 +08:00
|
|
|
|
} finally {
|
2026-03-04 00:47:41 +08:00
|
|
|
|
state.loading = false;
|
2026-02-17 00:12:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const loadMovements = async () => {
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
try {
|
2026-03-15 11:39:28 +08:00
|
|
|
|
state.movements = await apiRequest('/api/stock-movements');
|
2026-03-04 00:47:41 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载变动记录');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 15:47:43 +08:00
|
|
|
|
const loadFinance = async () => {
|
|
|
|
|
|
state.loading = true;
|
|
|
|
|
|
try {
|
2026-03-15 22:04:28 +08:00
|
|
|
|
const selectedYear = Number(state.financePeriod.year) || new Date().getFullYear();
|
|
|
|
|
|
const selectedQuarter = state.financePeriod.quarter ? Number(state.financePeriod.quarter) : null;
|
|
|
|
|
|
const periodQuery = selectedQuarter
|
|
|
|
|
|
? `year=${selectedYear}&quarter=${selectedQuarter}`
|
|
|
|
|
|
: `year=${selectedYear}`;
|
|
|
|
|
|
|
|
|
|
|
|
const [
|
|
|
|
|
|
summary,
|
|
|
|
|
|
transactions,
|
|
|
|
|
|
receivables,
|
|
|
|
|
|
payables,
|
|
|
|
|
|
customerStatement,
|
|
|
|
|
|
supplierStatement,
|
|
|
|
|
|
customerProductStatement,
|
|
|
|
|
|
supplierProductStatement
|
|
|
|
|
|
] = await Promise.all([
|
|
|
|
|
|
apiRequest(`/api/finance/summary?${periodQuery}`),
|
|
|
|
|
|
apiRequest(`/api/finance/transactions?status=confirmed&limit=20&${periodQuery}`),
|
|
|
|
|
|
apiRequest(`/api/finance/receivables?limit=20&${periodQuery}`),
|
|
|
|
|
|
apiRequest(`/api/finance/payables?limit=20&${periodQuery}`),
|
|
|
|
|
|
apiRequest(`/api/finance/partner-statement/customer?${periodQuery}`),
|
|
|
|
|
|
apiRequest(`/api/finance/partner-statement/supplier?${periodQuery}`),
|
|
|
|
|
|
apiRequest(`/api/finance/partner-product-statement/customer?${periodQuery}`),
|
|
|
|
|
|
apiRequest(`/api/finance/partner-product-statement/supplier?${periodQuery}`)
|
2026-03-15 15:47:43 +08:00
|
|
|
|
]);
|
|
|
|
|
|
state.financeSummary = summary;
|
|
|
|
|
|
state.financeTransactions = transactions;
|
|
|
|
|
|
state.receivables = receivables;
|
|
|
|
|
|
state.payables = payables;
|
2026-03-15 22:04:28 +08:00
|
|
|
|
state.customerFinanceStatement = customerStatement.items || [];
|
|
|
|
|
|
state.supplierFinanceStatement = supplierStatement.items || [];
|
|
|
|
|
|
state.customerProductStatement = customerProductStatement.items || [];
|
|
|
|
|
|
state.supplierProductStatement = supplierProductStatement.items || [];
|
2026-03-15 15:47:43 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载财务数据');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 22:04:28 +08:00
|
|
|
|
const refreshFinanceByPeriod = () => {
|
|
|
|
|
|
if (state.activeTab === 'finance') {
|
|
|
|
|
|
loadFinance();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
const switchTab = (tab) => {
|
|
|
|
|
|
state.activeTab = tab;
|
|
|
|
|
|
switch (tab) {
|
|
|
|
|
|
case 'dashboard': loadDashboard(); break;
|
|
|
|
|
|
case 'products': loadProducts(); break;
|
|
|
|
|
|
case 'suppliers': loadSuppliers(); break;
|
|
|
|
|
|
case 'customers': loadCustomers(); break;
|
|
|
|
|
|
case 'inventory': loadInventory(); break;
|
|
|
|
|
|
case 'movements': loadMovements(); break;
|
2026-03-17 22:20:56 +08:00
|
|
|
|
case 'purchases': loadPurchaseOrders(); break;
|
2026-03-15 22:50:38 +08:00
|
|
|
|
case 'production': loadProductionOrders(); break;
|
2026-03-15 15:47:43 +08:00
|
|
|
|
case 'finance': loadFinance(); break;
|
2026-03-04 00:47:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 22:50:38 +08:00
|
|
|
|
const loadOrderProductionPlan = async (orderId) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
state.productionPlan = await apiRequest(`/api/sales-orders/${orderId}/production-plan`);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载领料建议');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const issueOrderMaterials = async (order) => {
|
|
|
|
|
|
if (!state.productionWarehouseId) {
|
|
|
|
|
|
addNotification('请先选择领料仓库', 'warning');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await apiRequest(`/api/sales-orders/${order.id}/issue-materials`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
warehouse_id: state.productionWarehouseId,
|
|
|
|
|
|
production_no: order.production_no || undefined
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
addNotification(`领料成功,成本偏差率 ${(result.cost_deviation_rate * 100).toFixed(2)}%`, 'success');
|
|
|
|
|
|
await loadProductionOrders();
|
|
|
|
|
|
await loadMovements();
|
|
|
|
|
|
state.productionPlan = await apiRequest(`/api/sales-orders/${order.id}/production-plan`);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '执行领料');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 22:04:28 +08:00
|
|
|
|
const openModal = async (type, item = null) => {
|
2026-03-15 13:50:23 +08:00
|
|
|
|
state.modalType = type;
|
|
|
|
|
|
state.editingItem = item;
|
|
|
|
|
|
if (item) {
|
2026-03-17 22:20:56 +08:00
|
|
|
|
if (type === 'salesOrder') {
|
|
|
|
|
|
await loadCustomers();
|
|
|
|
|
|
await loadProducts();
|
|
|
|
|
|
const detail = await apiRequest(`/api/sales-orders/${item.id}`);
|
|
|
|
|
|
state.form = {
|
|
|
|
|
|
customer_id: detail.customer_id,
|
|
|
|
|
|
delivery_date: detail.delivery_date ? new Date(detail.delivery_date).toISOString().slice(0, 16) : '',
|
|
|
|
|
|
remark: detail.remark || '',
|
|
|
|
|
|
items: (detail.items || []).map(line => ({
|
|
|
|
|
|
product_id: line.product_id,
|
|
|
|
|
|
quantity: line.quantity,
|
|
|
|
|
|
unit_price: line.unit_price,
|
|
|
|
|
|
remark: line.remark || ''
|
|
|
|
|
|
}))
|
|
|
|
|
|
};
|
|
|
|
|
|
} else if (type === 'purchaseOrder') {
|
|
|
|
|
|
await loadSuppliers();
|
|
|
|
|
|
await loadMaterials();
|
|
|
|
|
|
const detail = await apiRequest(`/api/purchase-orders/${item.id}`);
|
|
|
|
|
|
state.form = {
|
|
|
|
|
|
supplier_id: detail.supplier_id,
|
|
|
|
|
|
expected_date: detail.expected_date ? new Date(detail.expected_date).toISOString().slice(0, 16) : '',
|
|
|
|
|
|
remark: detail.remark || '',
|
|
|
|
|
|
items: (detail.items || []).map(line => ({
|
|
|
|
|
|
product_id: line.product_id,
|
|
|
|
|
|
quantity: line.quantity,
|
|
|
|
|
|
unit_price: line.unit_price,
|
|
|
|
|
|
remark: line.remark || ''
|
|
|
|
|
|
}))
|
|
|
|
|
|
};
|
|
|
|
|
|
} else if (type === 'purchaseReceive') {
|
|
|
|
|
|
await loadWarehouses();
|
|
|
|
|
|
const detail = await apiRequest(`/api/purchase-orders/${item.id}`);
|
|
|
|
|
|
state.purchaseReceiveItems = (detail.items || [])
|
|
|
|
|
|
.map(line => ({
|
|
|
|
|
|
item_id: line.id,
|
|
|
|
|
|
material_label: `${line.product_sku || line.product_id} - ${line.product_name || ''}`.trim(),
|
|
|
|
|
|
remaining_quantity: Math.max((line.quantity || 0) - (line.received_quantity || 0), 0),
|
|
|
|
|
|
receive_quantity: Math.max((line.quantity || 0) - (line.received_quantity || 0), 0)
|
|
|
|
|
|
}))
|
|
|
|
|
|
.filter(line => line.remaining_quantity > 0);
|
|
|
|
|
|
state.form = {
|
|
|
|
|
|
warehouse_id: state.purchaseWarehouseId || state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null,
|
|
|
|
|
|
remark: ''
|
|
|
|
|
|
};
|
|
|
|
|
|
} else {
|
|
|
|
|
|
state.form = { ...item };
|
|
|
|
|
|
}
|
2026-03-15 13:50:23 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
state.form = {};
|
2026-03-17 22:20:56 +08:00
|
|
|
|
if (type === 'inventoryItem') {
|
2026-03-15 22:13:51 +08:00
|
|
|
|
await ensureStockBaseData();
|
2026-03-15 22:04:28 +08:00
|
|
|
|
state.form = {
|
2026-03-15 22:50:38 +08:00
|
|
|
|
product_id: state.materials[0]?.id || null,
|
2026-03-15 22:04:28 +08:00
|
|
|
|
warehouse_id: state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null,
|
2026-03-17 22:20:56 +08:00
|
|
|
|
quantity: 0,
|
|
|
|
|
|
locked_quantity: 0,
|
|
|
|
|
|
batch_number: '',
|
|
|
|
|
|
location: ''
|
2026-03-15 22:04:28 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-03-15 22:50:38 +08:00
|
|
|
|
if (type === 'product') {
|
|
|
|
|
|
state.form = {
|
|
|
|
|
|
item_type: 'finished',
|
|
|
|
|
|
unit: '件',
|
|
|
|
|
|
min_stock: 0,
|
|
|
|
|
|
max_stock: 1000,
|
|
|
|
|
|
cost_price: 0,
|
|
|
|
|
|
sale_price: 0
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-03-17 22:20:56 +08:00
|
|
|
|
if (type === 'salesOrder') {
|
|
|
|
|
|
await loadCustomers();
|
|
|
|
|
|
await loadProducts();
|
|
|
|
|
|
state.form = {
|
|
|
|
|
|
customer_id: state.customers[0]?.id || null,
|
|
|
|
|
|
delivery_date: '',
|
|
|
|
|
|
remark: '',
|
|
|
|
|
|
items: [{
|
|
|
|
|
|
product_id: state.products.find(p => p.item_type === 'finished')?.id || null,
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
unit_price: 0,
|
|
|
|
|
|
remark: ''
|
|
|
|
|
|
}]
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (type === 'purchaseOrder') {
|
|
|
|
|
|
await loadSuppliers();
|
|
|
|
|
|
await loadMaterials();
|
|
|
|
|
|
state.form = {
|
|
|
|
|
|
supplier_id: state.suppliers[0]?.id || null,
|
|
|
|
|
|
expected_date: '',
|
|
|
|
|
|
remark: '',
|
|
|
|
|
|
items: [{
|
|
|
|
|
|
product_id: state.materials[0]?.id || null,
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
unit_price: 0,
|
|
|
|
|
|
remark: ''
|
|
|
|
|
|
}]
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-03-15 13:50:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
state.showModal = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const closeModal = () => {
|
|
|
|
|
|
state.showModal = false;
|
|
|
|
|
|
state.modalType = '';
|
|
|
|
|
|
state.editingItem = null;
|
2026-03-15 22:50:38 +08:00
|
|
|
|
state.productBomItems = [];
|
2026-03-17 22:20:56 +08:00
|
|
|
|
state.purchaseReceiveItems = [];
|
2026-03-15 13:50:23 +08:00
|
|
|
|
state.form = {};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const saveProduct = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (state.editingItem) {
|
|
|
|
|
|
await apiRequest(`/api/products/${state.editingItem.id}`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
body: JSON.stringify(state.form)
|
|
|
|
|
|
});
|
2026-03-15 15:47:43 +08:00
|
|
|
|
addNotification('产品更新成功', 'success');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
await apiRequest('/api/products', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify(state.form)
|
|
|
|
|
|
});
|
2026-03-15 15:47:43 +08:00
|
|
|
|
addNotification('产品创建成功', 'success');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
closeModal();
|
|
|
|
|
|
loadProducts();
|
2026-03-15 22:50:38 +08:00
|
|
|
|
loadMaterials();
|
2026-03-15 13:50:23 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '保存产品');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const deleteProduct = async (id) => {
|
|
|
|
|
|
if (!confirm('确定要删除这个产品吗?')) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiRequest(`/api/products/${id}`, { method: 'DELETE' });
|
2026-03-15 15:47:43 +08:00
|
|
|
|
addNotification('产品已删除', 'success');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
loadProducts();
|
2026-03-15 22:50:38 +08:00
|
|
|
|
loadMaterials();
|
2026-03-15 13:50:23 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '删除产品');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 22:50:38 +08:00
|
|
|
|
const editProductBom = async (product) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await loadMaterials();
|
|
|
|
|
|
const bom = await apiRequest(`/api/products/${product.id}/materials`);
|
|
|
|
|
|
state.modalType = 'productBom';
|
|
|
|
|
|
state.editingItem = product;
|
|
|
|
|
|
state.productBomItems = (bom.items || []).map(item => ({
|
|
|
|
|
|
material_id: item.material_id,
|
|
|
|
|
|
quantity: item.quantity,
|
|
|
|
|
|
loss_rate: item.loss_rate
|
|
|
|
|
|
}));
|
|
|
|
|
|
state.showModal = true;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '加载产品BOM');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addBomItem = () => {
|
|
|
|
|
|
state.productBomItems.push({
|
|
|
|
|
|
material_id: state.materials[0]?.id || null,
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
loss_rate: 0
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const removeBomItem = (idx) => {
|
|
|
|
|
|
state.productBomItems.splice(idx, 1);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const saveProductBom = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiRequest(`/api/products/${state.editingItem.id}/materials`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
body: JSON.stringify({ items: state.productBomItems })
|
|
|
|
|
|
});
|
|
|
|
|
|
addNotification('产品BOM保存成功', 'success');
|
|
|
|
|
|
closeModal();
|
|
|
|
|
|
loadProducts();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '保存产品BOM');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 13:50:23 +08:00
|
|
|
|
const saveSupplier = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (state.editingItem) {
|
|
|
|
|
|
await apiRequest(`/api/suppliers/${state.editingItem.id}`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
body: JSON.stringify(state.form)
|
|
|
|
|
|
});
|
2026-03-15 15:47:43 +08:00
|
|
|
|
addNotification('供应商更新成功', 'success');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
await apiRequest('/api/suppliers', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify(state.form)
|
|
|
|
|
|
});
|
2026-03-15 15:47:43 +08:00
|
|
|
|
addNotification('供应商创建成功', 'success');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
closeModal();
|
|
|
|
|
|
loadSuppliers();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '保存供应商');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const deleteSupplier = async (id) => {
|
|
|
|
|
|
if (!confirm('确定要删除这个供应商吗?')) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiRequest(`/api/suppliers/${id}`, { method: 'DELETE' });
|
2026-03-15 15:47:43 +08:00
|
|
|
|
addNotification('供应商已删除', 'success');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
loadSuppliers();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '删除供应商');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const saveCustomer = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (state.editingItem) {
|
|
|
|
|
|
await apiRequest(`/api/customers/${state.editingItem.id}`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
body: JSON.stringify(state.form)
|
|
|
|
|
|
});
|
2026-03-15 15:47:43 +08:00
|
|
|
|
addNotification('客户更新成功', 'success');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
await apiRequest('/api/customers', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify(state.form)
|
|
|
|
|
|
});
|
2026-03-15 15:47:43 +08:00
|
|
|
|
addNotification('客户创建成功', 'success');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
closeModal();
|
|
|
|
|
|
loadCustomers();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '保存客户');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const deleteCustomer = async (id) => {
|
|
|
|
|
|
if (!confirm('确定要删除这个客户吗?')) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiRequest(`/api/customers/${id}`, { method: 'DELETE' });
|
2026-03-15 15:47:43 +08:00
|
|
|
|
addNotification('客户已删除', 'success');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
loadCustomers();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '删除客户');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-17 22:20:56 +08:00
|
|
|
|
const saveInventoryItem = async () => {
|
2026-03-15 13:50:23 +08:00
|
|
|
|
try {
|
2026-03-17 22:20:56 +08:00
|
|
|
|
if (state.editingItem) {
|
|
|
|
|
|
await apiRequest(`/api/inventory/${state.editingItem.id}`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
quantity: state.form.quantity,
|
|
|
|
|
|
locked_quantity: state.form.locked_quantity,
|
|
|
|
|
|
batch_number: state.form.batch_number,
|
|
|
|
|
|
location: state.form.location
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
addNotification('物料库存更新成功', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await apiRequest('/api/inventory', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify(state.form)
|
|
|
|
|
|
});
|
|
|
|
|
|
addNotification('物料库存创建成功', 'success');
|
|
|
|
|
|
}
|
|
|
|
|
|
closeModal();
|
|
|
|
|
|
loadInventory();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '保存物料库存');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const deleteInventoryItem = async (id) => {
|
|
|
|
|
|
if (!confirm('确定要删除这个物料库存记录吗?')) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiRequest(`/api/inventory/${id}`, { method: 'DELETE' });
|
|
|
|
|
|
addNotification('物料库存已删除', 'success');
|
|
|
|
|
|
loadInventory();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '删除物料库存');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addSalesOrderItem = () => {
|
|
|
|
|
|
state.form.items = state.form.items || [];
|
|
|
|
|
|
state.form.items.push({
|
|
|
|
|
|
product_id: state.products.find(p => p.item_type === 'finished')?.id || null,
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
unit_price: 0,
|
|
|
|
|
|
remark: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const removeSalesOrderItem = (index) => {
|
|
|
|
|
|
state.form.items.splice(index, 1);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const saveSalesOrder = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!state.form.items || !state.form.items.length) {
|
|
|
|
|
|
addNotification('请至少添加一个成品明细', 'warning');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
customer_id: state.form.customer_id,
|
|
|
|
|
|
delivery_date: state.form.delivery_date ? new Date(state.form.delivery_date).toISOString() : null,
|
|
|
|
|
|
remark: state.form.remark,
|
|
|
|
|
|
items: state.form.items
|
|
|
|
|
|
};
|
|
|
|
|
|
if (state.editingItem) {
|
|
|
|
|
|
await apiRequest(`/api/sales-orders/${state.editingItem.id}`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
|
});
|
|
|
|
|
|
addNotification('销售订单更新成功', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await apiRequest('/api/sales-orders', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
|
});
|
|
|
|
|
|
addNotification('销售订单创建成功并已自动扣减物料', 'success');
|
|
|
|
|
|
}
|
2026-03-15 13:50:23 +08:00
|
|
|
|
closeModal();
|
2026-03-17 22:20:56 +08:00
|
|
|
|
loadProductionOrders();
|
2026-03-15 13:50:23 +08:00
|
|
|
|
loadInventory();
|
|
|
|
|
|
loadMovements();
|
|
|
|
|
|
} catch (e) {
|
2026-03-17 22:20:56 +08:00
|
|
|
|
handleApiError(e, '保存销售订单');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const deleteSalesOrder = async (orderId) => {
|
|
|
|
|
|
if (!confirm('确定删除这个销售订单吗?系统会自动回补已扣减物料。')) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiRequest(`/api/sales-orders/${orderId}`, { method: 'DELETE' });
|
|
|
|
|
|
addNotification('销售订单已删除并回补物料', 'success');
|
|
|
|
|
|
if (state.productionPlan?.sales_order_id === orderId) {
|
|
|
|
|
|
state.productionPlan = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
loadProductionOrders();
|
|
|
|
|
|
loadInventory();
|
|
|
|
|
|
loadMovements();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '删除销售订单');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addPurchaseOrderItem = () => {
|
|
|
|
|
|
state.form.items = state.form.items || [];
|
|
|
|
|
|
state.form.items.push({
|
|
|
|
|
|
product_id: state.materials[0]?.id || null,
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
unit_price: 0,
|
|
|
|
|
|
remark: ''
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const removePurchaseOrderItem = (index) => {
|
|
|
|
|
|
state.form.items.splice(index, 1);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const savePurchaseOrder = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!state.form.items || !state.form.items.length) {
|
|
|
|
|
|
addNotification('请至少添加一个物料明细', 'warning');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
supplier_id: state.form.supplier_id,
|
|
|
|
|
|
expected_date: state.form.expected_date ? new Date(state.form.expected_date).toISOString() : null,
|
|
|
|
|
|
remark: state.form.remark,
|
|
|
|
|
|
items: state.form.items
|
|
|
|
|
|
};
|
|
|
|
|
|
if (state.editingItem) {
|
|
|
|
|
|
await apiRequest(`/api/purchase-orders/${state.editingItem.id}`, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
|
});
|
|
|
|
|
|
addNotification('采购订单更新成功', 'success');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await apiRequest('/api/purchase-orders', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
|
});
|
|
|
|
|
|
addNotification('采购订单创建成功', 'success');
|
|
|
|
|
|
}
|
|
|
|
|
|
closeModal();
|
|
|
|
|
|
loadPurchaseOrders();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '保存采购订单');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-17 22:20:56 +08:00
|
|
|
|
const deletePurchaseOrder = async (orderId) => {
|
|
|
|
|
|
if (!confirm('确定删除这个采购订单吗?')) return;
|
2026-03-15 13:50:23 +08:00
|
|
|
|
try {
|
2026-03-17 22:20:56 +08:00
|
|
|
|
await apiRequest(`/api/purchase-orders/${orderId}`, { method: 'DELETE' });
|
|
|
|
|
|
addNotification('采购订单已删除', 'success');
|
|
|
|
|
|
loadPurchaseOrders();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
handleApiError(e, '删除采购订单');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const receivePurchaseOrder = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!state.editingItem?.id) return;
|
|
|
|
|
|
const items = (state.purchaseReceiveItems || [])
|
|
|
|
|
|
.filter(line => Number(line.receive_quantity) > 0)
|
|
|
|
|
|
.map(line => ({
|
|
|
|
|
|
item_id: line.item_id,
|
|
|
|
|
|
receive_quantity: Number(line.receive_quantity)
|
|
|
|
|
|
}));
|
|
|
|
|
|
if (!items.length) {
|
|
|
|
|
|
addNotification('请填写本次入库数量', 'warning');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await apiRequest(`/api/purchase-orders/${state.editingItem.id}/receive`, {
|
2026-03-15 13:50:23 +08:00
|
|
|
|
method: 'POST',
|
2026-03-17 22:20:56 +08:00
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
warehouse_id: state.form.warehouse_id || state.purchaseWarehouseId,
|
|
|
|
|
|
items,
|
|
|
|
|
|
remark: state.form.remark || ''
|
|
|
|
|
|
})
|
2026-03-15 13:50:23 +08:00
|
|
|
|
});
|
2026-03-17 22:20:56 +08:00
|
|
|
|
addNotification('采购到货入库成功', 'success');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
closeModal();
|
2026-03-17 22:20:56 +08:00
|
|
|
|
loadPurchaseOrders();
|
2026-03-15 13:50:23 +08:00
|
|
|
|
loadInventory();
|
|
|
|
|
|
loadMovements();
|
|
|
|
|
|
} catch (e) {
|
2026-03-17 22:20:56 +08:00
|
|
|
|
handleApiError(e, '采购到货入库');
|
2026-03-15 13:50:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
if (!appState.user) {
|
|
|
|
|
|
router.push('/login');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
loadDashboard();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
state,
|
|
|
|
|
|
switchTab,
|
|
|
|
|
|
formatNumber,
|
|
|
|
|
|
formatCurrency,
|
2026-03-15 13:50:23 +08:00
|
|
|
|
formatDateTime,
|
|
|
|
|
|
openModal,
|
|
|
|
|
|
closeModal,
|
|
|
|
|
|
saveProduct,
|
|
|
|
|
|
deleteProduct,
|
2026-03-15 22:50:38 +08:00
|
|
|
|
editProductBom,
|
|
|
|
|
|
addBomItem,
|
|
|
|
|
|
removeBomItem,
|
|
|
|
|
|
saveProductBom,
|
2026-03-15 13:50:23 +08:00
|
|
|
|
saveSupplier,
|
|
|
|
|
|
deleteSupplier,
|
|
|
|
|
|
saveCustomer,
|
|
|
|
|
|
deleteCustomer,
|
2026-03-17 22:20:56 +08:00
|
|
|
|
saveInventoryItem,
|
|
|
|
|
|
deleteInventoryItem,
|
|
|
|
|
|
addSalesOrderItem,
|
|
|
|
|
|
removeSalesOrderItem,
|
|
|
|
|
|
saveSalesOrder,
|
|
|
|
|
|
deleteSalesOrder,
|
|
|
|
|
|
addPurchaseOrderItem,
|
|
|
|
|
|
removePurchaseOrderItem,
|
|
|
|
|
|
savePurchaseOrder,
|
|
|
|
|
|
deletePurchaseOrder,
|
|
|
|
|
|
receivePurchaseOrder,
|
|
|
|
|
|
loadPurchaseOrders,
|
2026-03-15 22:50:38 +08:00
|
|
|
|
loadProductionOrders,
|
|
|
|
|
|
loadOrderProductionPlan,
|
|
|
|
|
|
issueOrderMaterials,
|
2026-03-15 22:30:41 +08:00
|
|
|
|
refreshFinanceByPeriod,
|
|
|
|
|
|
getMovementTypeLabel,
|
|
|
|
|
|
getMovementBadgeClass
|
2026-02-17 00:12:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
template: `
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="page-container">
|
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
|
<h1>进销存管理</h1>
|
|
|
|
|
|
<p>库存、采购、销售管理</p>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="tabs">
|
|
|
|
|
|
<button :class="['tab', { active: state.activeTab === 'dashboard' }]" @click="switchTab('dashboard')">仪表盘</button>
|
|
|
|
|
|
<button :class="['tab', { active: state.activeTab === 'products' }]" @click="switchTab('products')">产品</button>
|
|
|
|
|
|
<button :class="['tab', { active: state.activeTab === 'inventory' }]" @click="switchTab('inventory')">库存</button>
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<button :class="['tab', { active: state.activeTab === 'purchases' }]" @click="switchTab('purchases')">采购</button>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<button :class="['tab', { active: state.activeTab === 'suppliers' }]" @click="switchTab('suppliers')">供应商</button>
|
|
|
|
|
|
<button :class="['tab', { active: state.activeTab === 'customers' }]" @click="switchTab('customers')">客户</button>
|
|
|
|
|
|
<button :class="['tab', { active: state.activeTab === 'movements' }]" @click="switchTab('movements')">变动记录</button>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<button :class="['tab', { active: state.activeTab === 'production' }]" @click="switchTab('production')">按单生产</button>
|
2026-03-15 15:47:43 +08:00
|
|
|
|
<button :class="['tab', { active: state.activeTab === 'finance' }]" @click="switchTab('finance')">财务</button>
|
2026-03-04 00:10:05 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
|
|
|
|
|
<div v-if="state.loading" class="loading-state">
|
|
|
|
|
|
<div class="spinner"></div>
|
|
|
|
|
|
<span>加载中...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else>
|
|
|
|
|
|
<div v-if="state.activeTab === 'dashboard'" class="dashboard-grid">
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-icon">📦</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ state.dashboard?.product_count || 0 }}</div>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<div class="stat-label">成品数量</div>
|
2026-03-03 23:57:04 +08:00
|
|
|
|
</div>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-icon">📊</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ state.dashboard?.total_stock || 0 }}</div>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<div class="stat-label">物料库存总量</div>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-icon">💰</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ formatCurrency(state.dashboard?.total_value || 0) }}</div>
|
|
|
|
|
|
<div class="stat-label">库存价值</div>
|
2026-03-03 23:57:04 +08:00
|
|
|
|
</div>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-icon">🏭</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ state.dashboard?.supplier_count || 0 }}</div>
|
|
|
|
|
|
<div class="stat-label">供应商</div>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-icon">👥</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ state.dashboard?.customer_count || 0 }}</div>
|
|
|
|
|
|
<div class="stat-label">客户</div>
|
2026-03-04 00:10:05 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-icon">🏪</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ state.dashboard?.warehouse_count || 0 }}</div>
|
|
|
|
|
|
<div class="stat-label">仓库</div>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-04 00:10:05 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<div v-else-if="state.activeTab === 'products'">
|
|
|
|
|
|
<div class="table-header">
|
|
|
|
|
|
<button class="btn btn-primary" @click="openModal('product')">+ 新增产品</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-container">
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<th>类型</th>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<th>SKU</th>
|
|
|
|
|
|
<th>名称</th>
|
|
|
|
|
|
<th>分类</th>
|
|
|
|
|
|
<th>单位</th>
|
|
|
|
|
|
<th>成本价</th>
|
|
|
|
|
|
<th>销售价</th>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<th>基础物料成本</th>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<th>操作</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="product in state.products" :key="product.id">
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<td>{{ product.item_type === 'material' ? '物料' : '成品' }}</td>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<td>{{ product.sku }}</td>
|
|
|
|
|
|
<td>{{ product.name }}</td>
|
|
|
|
|
|
<td>{{ product.category || '-' }}</td>
|
|
|
|
|
|
<td>{{ product.unit }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(product.cost_price) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(product.sale_price) }}</td>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<td>{{ product.item_type === 'finished' ? formatCurrency(product.material_cost || 0) : '-' }}</td>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<td>
|
|
|
|
|
|
<div class="action-btns">
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<button v-if="product.item_type === 'finished'" class="btn btn-sm btn-secondary" @click="editProductBom(product)">BOM</button>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<button class="btn btn-sm btn-secondary" @click="openModal('product', product)">编辑</button>
|
|
|
|
|
|
<button class="btn btn-sm btn-danger" @click="deleteProduct(product.id)">删除</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<div v-else-if="state.activeTab === 'inventory'">
|
|
|
|
|
|
<div class="table-header">
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<button class="btn btn-primary" @click="openModal('inventoryItem')">+ 新增物料库存</button>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-container">
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>SKU</th>
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<th>物料</th>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<th>仓库</th>
|
|
|
|
|
|
<th>数量</th>
|
|
|
|
|
|
<th>可用</th>
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<th>操作</th>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="item in state.inventory" :key="item.id">
|
|
|
|
|
|
<td>{{ item.product_sku }}</td>
|
|
|
|
|
|
<td>{{ item.product_name }}</td>
|
|
|
|
|
|
<td>{{ item.warehouse_name }}</td>
|
|
|
|
|
|
<td>{{ item.quantity }}</td>
|
|
|
|
|
|
<td>{{ item.available_quantity }}</td>
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<td>
|
|
|
|
|
|
<div class="action-btns">
|
|
|
|
|
|
<button class="btn btn-sm btn-secondary" @click="openModal('inventoryItem', item)">编辑</button>
|
|
|
|
|
|
<button class="btn btn-sm btn-danger" @click="deleteInventoryItem(item.id)">删除</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else-if="state.activeTab === 'purchases'">
|
|
|
|
|
|
<div class="table-header" style="margin-bottom: 12px;">
|
|
|
|
|
|
<button class="btn btn-primary" @click="openModal('purchaseOrder')">+ 新增采购订单</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-container" style="margin-bottom: 16px;">
|
|
|
|
|
|
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
|
|
|
|
|
<label>到货仓库</label>
|
|
|
|
|
|
<select v-model.number="state.purchaseWarehouseId" class="form-input" style="width:260px;">
|
|
|
|
|
|
<option v-for="warehouse in state.warehouses" :key="'purchase-warehouse-' + warehouse.id" :value="warehouse.id">
|
|
|
|
|
|
{{ warehouse.name }}{{ warehouse.is_default ? ' [默认]' : '' }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<button class="btn btn-secondary" @click="loadPurchaseOrders">刷新</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<tr v-for="order in state.purchaseOrders" :key="'purchase-order-' + order.id">
|
|
|
|
|
|
<td>{{ order.order_no }}</td>
|
|
|
|
|
|
<td>{{ order.supplier_name }}</td>
|
|
|
|
|
|
<td>{{ order.status }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(order.total_amount || 0) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(order.paid_amount || 0) }}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div class="action-btns">
|
|
|
|
|
|
<button class="btn btn-sm btn-secondary" @click="openModal('purchaseOrder', order)">编辑</button>
|
|
|
|
|
|
<button class="btn btn-sm btn-danger" @click="deletePurchaseOrder(order.id)">删除</button>
|
|
|
|
|
|
<button class="btn btn-sm btn-primary" @click="openModal('purchaseReceive', order)">到货入库</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<div v-else-if="state.activeTab === 'suppliers'">
|
|
|
|
|
|
<div class="table-header">
|
|
|
|
|
|
<button class="btn btn-primary" @click="openModal('supplier')">+ 新增供应商</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<tr v-for="supplier in state.suppliers" :key="supplier.id">
|
|
|
|
|
|
<td>{{ supplier.code }}</td>
|
|
|
|
|
|
<td>{{ supplier.name }}</td>
|
|
|
|
|
|
<td>{{ supplier.contact_person || '-' }}</td>
|
|
|
|
|
|
<td>{{ supplier.phone || '-' }}</td>
|
|
|
|
|
|
<td>{{ supplier.email || '-' }}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div class="action-btns">
|
|
|
|
|
|
<button class="btn btn-sm btn-secondary" @click="openModal('supplier', supplier)">编辑</button>
|
|
|
|
|
|
<button class="btn btn-sm btn-danger" @click="deleteSupplier(supplier.id)">删除</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<div v-else-if="state.activeTab === 'customers'">
|
|
|
|
|
|
<div class="table-header">
|
|
|
|
|
|
<button class="btn btn-primary" @click="openModal('customer')">+ 新增客户</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<tr v-for="customer in state.customers" :key="customer.id">
|
|
|
|
|
|
<td>{{ customer.code }}</td>
|
|
|
|
|
|
<td>{{ customer.name }}</td>
|
|
|
|
|
|
<td>{{ customer.contact_person || '-' }}</td>
|
|
|
|
|
|
<td>{{ customer.phone || '-' }}</td>
|
|
|
|
|
|
<td>{{ customer.email || '-' }}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div class="action-btns">
|
|
|
|
|
|
<button class="btn btn-sm btn-secondary" @click="openModal('customer', customer)">编辑</button>
|
|
|
|
|
|
<button class="btn btn-sm btn-danger" @click="deleteCustomer(customer.id)">删除</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<div v-else-if="state.activeTab === 'production'">
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<div class="table-header" style="margin-bottom: 12px;">
|
|
|
|
|
|
<button class="btn btn-primary" @click="openModal('salesOrder')">+ 新增销售订单</button>
|
|
|
|
|
|
</div>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<div class="table-container" style="margin-bottom: 16px;">
|
|
|
|
|
|
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
|
|
|
|
|
<label>领料仓库</label>
|
|
|
|
|
|
<select v-model.number="state.productionWarehouseId" class="form-input" style="width:260px;">
|
|
|
|
|
|
<option v-for="warehouse in state.warehouses" :key="'production-warehouse-' + warehouse.id" :value="warehouse.id">
|
|
|
|
|
|
{{ warehouse.name }}{{ warehouse.is_default ? ' [默认]' : '' }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<button class="btn btn-secondary" @click="loadProductionOrders">刷新</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-container">
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>销售单</th>
|
|
|
|
|
|
<th>客户</th>
|
|
|
|
|
|
<th>生产单号</th>
|
|
|
|
|
|
<th>生产状态</th>
|
|
|
|
|
|
<th>计划物料成本</th>
|
|
|
|
|
|
<th>实际领料成本</th>
|
|
|
|
|
|
<th>操作</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="order in state.productionOrders" :key="'production-order-' + order.id">
|
|
|
|
|
|
<td>{{ order.order_no }}</td>
|
|
|
|
|
|
<td>{{ order.customer_name }}</td>
|
|
|
|
|
|
<td>{{ order.production_no || '-' }}</td>
|
|
|
|
|
|
<td>{{ order.production_status || '-' }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(order.planned_material_cost || 0) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(order.actual_material_cost || 0) }}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div class="action-btns">
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<button class="btn btn-sm btn-secondary" @click="openModal('salesOrder', order)">编辑</button>
|
|
|
|
|
|
<button class="btn btn-sm btn-danger" @click="deleteSalesOrder(order.id)">删除</button>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<button class="btn btn-sm btn-secondary" @click="loadOrderProductionPlan(order.id)">领料建议</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="state.productionPlan" class="table-container" style="margin-top: 16px;">
|
|
|
|
|
|
<h3 style="margin-bottom: 12px;">领料建议:{{ state.productionPlan.order_no }}({{ state.productionPlan.production_no }})</h3>
|
|
|
|
|
|
<div style="margin-bottom: 12px; color: var(--text-secondary);">
|
|
|
|
|
|
计划物料成本:{{ formatCurrency(state.productionPlan.planned_material_cost || 0) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>物料</th>
|
|
|
|
|
|
<th>需求</th>
|
|
|
|
|
|
<th>可用</th>
|
|
|
|
|
|
<th>缺口</th>
|
|
|
|
|
|
<th>单位成本</th>
|
|
|
|
|
|
<th>需求成本</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="item in state.productionPlan.items" :key="'plan-material-' + item.material_id">
|
|
|
|
|
|
<td>{{ item.material_sku }} - {{ item.material_name }}</td>
|
|
|
|
|
|
<td>{{ item.required_quantity }}</td>
|
|
|
|
|
|
<td>{{ item.available_quantity }}</td>
|
|
|
|
|
|
<td :class="{ 'text-warning': item.shortage_quantity > 0 }">{{ item.shortage_quantity }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.unit_cost) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.required_cost) }}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-15 15:47:43 +08:00
|
|
|
|
<div v-else-if="state.activeTab === 'finance'">
|
2026-03-15 22:04:28 +08:00
|
|
|
|
<div class="table-container" style="margin-bottom: 16px;">
|
|
|
|
|
|
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style="margin-right:8px;">年份</label>
|
|
|
|
|
|
<input v-model.number="state.financePeriod.year" type="number" min="2000" max="2100" class="form-input" style="width:120px; display:inline-block;" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label style="margin-right:8px;">季度</label>
|
|
|
|
|
|
<select v-model="state.financePeriod.quarter" class="form-input" style="width:140px; display:inline-block;">
|
|
|
|
|
|
<option value="">全年</option>
|
|
|
|
|
|
<option value="1">Q1</option>
|
|
|
|
|
|
<option value="2">Q2</option>
|
|
|
|
|
|
<option value="3">Q3</option>
|
|
|
|
|
|
<option value="4">Q4</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button class="btn btn-primary" @click="refreshFinanceByPeriod">刷新统计</button>
|
|
|
|
|
|
<span style="color:var(--text-secondary);">统计周期:{{ state.financeSummary?.period_label || '-' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-15 15:47:43 +08:00
|
|
|
|
<div class="dashboard-grid">
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-icon">🧾</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ formatCurrency(state.financeSummary?.receivable_total || 0) }}</div>
|
|
|
|
|
|
<div class="stat-label">应收总额</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-icon">💸</div>
|
|
|
|
|
|
<div class="stat-content">
|
|
|
|
|
|
<div class="stat-value">{{ formatCurrency(state.financeSummary?.payable_total || 0) }}</div>
|
|
|
|
|
|
<div class="stat-label">应付总额</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-icon">💵</div>
|
|
|
|
|
|
<div class="stat-content">
|
2026-03-15 22:04:28 +08:00
|
|
|
|
<div class="stat-value">{{ formatCurrency(state.financeSummary?.period_receipt_total || 0) }}</div>
|
|
|
|
|
|
<div class="stat-label">周期收款</div>
|
2026-03-15 15:47:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-icon">🏦</div>
|
|
|
|
|
|
<div class="stat-content">
|
2026-03-15 22:04:28 +08:00
|
|
|
|
<div class="stat-value">{{ formatCurrency(state.financeSummary?.period_payment_total || 0) }}</div>
|
|
|
|
|
|
<div class="stat-label">周期付款</div>
|
2026-03-15 15:47:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-15 22:04:28 +08:00
|
|
|
|
<div class="table-container" style="margin-top: 16px;">
|
|
|
|
|
|
<h3 style="margin-bottom: 12px;">客户账款(周期)</h3>
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>客户</th>
|
|
|
|
|
|
<th>订单数</th>
|
|
|
|
|
|
<th>流水数</th>
|
|
|
|
|
|
<th>订单金额</th>
|
|
|
|
|
|
<th>订单已收</th>
|
|
|
|
|
|
<th>实收流水</th>
|
|
|
|
|
|
<th>应收余额</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="item in state.customerFinanceStatement" :key="'customer-' + item.partner_id">
|
|
|
|
|
|
<td>{{ item.partner_name }}</td>
|
|
|
|
|
|
<td>{{ item.order_count }}</td>
|
|
|
|
|
|
<td>{{ item.transaction_count }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.order_total) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.settled_total) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.transaction_total) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.outstanding_total) }}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="table-container" style="margin-top: 16px;">
|
|
|
|
|
|
<h3 style="margin-bottom: 12px;">供应商账款(周期)</h3>
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>供应商</th>
|
|
|
|
|
|
<th>订单数</th>
|
|
|
|
|
|
<th>流水数</th>
|
|
|
|
|
|
<th>订单金额</th>
|
|
|
|
|
|
<th>订单已付</th>
|
|
|
|
|
|
<th>实付流水</th>
|
|
|
|
|
|
<th>应付余额</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="item in state.supplierFinanceStatement" :key="'supplier-' + item.partner_id">
|
|
|
|
|
|
<td>{{ item.partner_name }}</td>
|
|
|
|
|
|
<td>{{ item.order_count }}</td>
|
|
|
|
|
|
<td>{{ item.transaction_count }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.order_total) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.settled_total) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.transaction_total) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.outstanding_total) }}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="table-container" style="margin-top: 16px;">
|
|
|
|
|
|
<h3 style="margin-bottom: 12px;">客户-商品追溯(周期)</h3>
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>客户</th>
|
|
|
|
|
|
<th>SKU</th>
|
|
|
|
|
|
<th>商品</th>
|
|
|
|
|
|
<th>订单数</th>
|
|
|
|
|
|
<th>数量</th>
|
|
|
|
|
|
<th>订单金额</th>
|
|
|
|
|
|
<th>已结款</th>
|
|
|
|
|
|
<th>未结款</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="item in state.customerProductStatement" :key="'customer-product-' + item.partner_id + '-' + item.product_id">
|
|
|
|
|
|
<td>{{ item.partner_name }}</td>
|
|
|
|
|
|
<td>{{ item.product_sku || '-' }}</td>
|
|
|
|
|
|
<td>{{ item.product_name }}</td>
|
|
|
|
|
|
<td>{{ item.order_count }}</td>
|
|
|
|
|
|
<td>{{ formatNumber(item.order_quantity) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.order_amount) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.settled_amount) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.outstanding_amount) }}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="table-container" style="margin-top: 16px;">
|
|
|
|
|
|
<h3 style="margin-bottom: 12px;">供应商-商品追溯(周期)</h3>
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>供应商</th>
|
|
|
|
|
|
<th>SKU</th>
|
|
|
|
|
|
<th>商品</th>
|
|
|
|
|
|
<th>订单数</th>
|
|
|
|
|
|
<th>数量</th>
|
|
|
|
|
|
<th>订单金额</th>
|
|
|
|
|
|
<th>已结款</th>
|
|
|
|
|
|
<th>未结款</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="item in state.supplierProductStatement" :key="'supplier-product-' + item.partner_id + '-' + item.product_id">
|
|
|
|
|
|
<td>{{ item.partner_name }}</td>
|
|
|
|
|
|
<td>{{ item.product_sku || '-' }}</td>
|
|
|
|
|
|
<td>{{ item.product_name }}</td>
|
|
|
|
|
|
<td>{{ item.order_count }}</td>
|
|
|
|
|
|
<td>{{ formatNumber(item.order_quantity) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.order_amount) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.settled_amount) }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(item.outstanding_amount) }}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-15 15:47:43 +08:00
|
|
|
|
<div class="table-container" style="margin-top: 16px;">
|
|
|
|
|
|
<h3 style="margin-bottom: 12px;">最近财务流水</h3>
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>单号</th>
|
|
|
|
|
|
<th>类型</th>
|
|
|
|
|
|
<th>往来方</th>
|
|
|
|
|
|
<th>金额</th>
|
|
|
|
|
|
<th>状态</th>
|
|
|
|
|
|
<th>日期</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="txn in state.financeTransactions" :key="txn.id">
|
|
|
|
|
|
<td>{{ txn.txn_no }}</td>
|
|
|
|
|
|
<td>{{ txn.txn_type === 'receipt' ? '收款' : '付款' }}</td>
|
|
|
|
|
|
<td>{{ txn.partner_type === 'customer' ? '客户' : '供应商' }}#{{ txn.partner_id }}</td>
|
|
|
|
|
|
<td>{{ formatCurrency(txn.amount) }}</td>
|
|
|
|
|
|
<td>{{ txn.status === 'confirmed' ? '已确认' : '已作废' }}</td>
|
|
|
|
|
|
<td>{{ formatDateTime(txn.txn_date) }}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<div v-else-if="state.activeTab === 'movements'" class="table-container">
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<th>物料</th>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<th>类型</th>
|
|
|
|
|
|
<th>数量</th>
|
|
|
|
|
|
<th>变动前</th>
|
|
|
|
|
|
<th>变动后</th>
|
|
|
|
|
|
<th>时间</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="movement in state.movements" :key="movement.id">
|
2026-03-15 22:04:28 +08:00
|
|
|
|
<td>{{ movement.product_name }}{{ movement.product_sku ? ' (' + movement.product_sku + ')' : '' }}</td>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
<td>
|
2026-03-15 22:30:41 +08:00
|
|
|
|
<span :class="['badge', getMovementBadgeClass(movement.movement_type)]">
|
|
|
|
|
|
{{ getMovementTypeLabel(movement.movement_type) }}
|
2026-03-04 00:47:41 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>{{ movement.quantity }}</td>
|
|
|
|
|
|
<td>{{ movement.before_quantity }}</td>
|
|
|
|
|
|
<td>{{ movement.after_quantity }}</td>
|
|
|
|
|
|
<td>{{ formatDateTime(movement.created_at) }}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 模态框 -->
|
|
|
|
|
|
<div v-if="state.showModal" class="modal-overlay" @click.self="closeModal">
|
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
|
<div class="modal-header">
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<h3>{{ state.editingItem ? '编辑' : '新增' }}{{ state.modalType === 'product' ? '产品/物料' : state.modalType === 'productBom' ? '产品BOM' : state.modalType === 'inventoryItem' ? '物料库存' : state.modalType === 'salesOrder' ? '销售订单' : state.modalType === 'purchaseOrder' ? '采购订单' : state.modalType === 'purchaseReceive' ? '采购到货入库' : state.modalType === 'supplier' ? '供应商' : '客户' }}</h3>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<button class="modal-close" @click="closeModal">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
|
<!-- 产品表单 -->
|
|
|
|
|
|
<form v-if="state.modalType === 'product'" @submit.prevent="saveProduct">
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">类型 *</label>
|
|
|
|
|
|
<select v-model="state.form.item_type" class="form-input" required>
|
|
|
|
|
|
<option value="finished">成品(按单生产,不做库存)</option>
|
|
|
|
|
|
<option value="material">物料(纳入库存)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">SKU *</label>
|
|
|
|
|
|
<input v-model="state.form.sku" class="form-input" required placeholder="产品编码" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">名称 *</label>
|
|
|
|
|
|
<input v-model="state.form.name" class="form-input" required placeholder="产品名称" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">分类</label>
|
|
|
|
|
|
<input v-model="state.form.category" class="form-input" placeholder="产品分类" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">单位</label>
|
|
|
|
|
|
<input v-model="state.form.unit" class="form-input" placeholder="件/个/箱" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">成本价</label>
|
|
|
|
|
|
<input v-model.number="state.form.cost_price" type="number" step="0.01" class="form-input" placeholder="0.00" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">销售价</label>
|
|
|
|
|
|
<input v-model.number="state.form.sale_price" type="number" step="0.01" class="form-input" placeholder="0.00" />
|
|
|
|
|
|
</div>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<div v-if="state.form.item_type === 'material'" class="form-group">
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<label class="form-label">最低库存</label>
|
|
|
|
|
|
<input v-model.number="state.form.min_stock" type="number" class="form-input" placeholder="0" />
|
|
|
|
|
|
</div>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<div v-if="state.form.item_type === 'finished'" class="form-group">
|
|
|
|
|
|
<label class="form-label">说明</label>
|
|
|
|
|
|
<input disabled value="成品不做库存,成本由下方BOM定义物料构成后自动计算" class="form-input" />
|
|
|
|
|
|
</div>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">保存</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<form v-else-if="state.modalType === 'salesOrder'" @submit.prevent="saveSalesOrder">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">客户 *</label>
|
|
|
|
|
|
<select v-model.number="state.form.customer_id" class="form-input" required>
|
|
|
|
|
|
<option v-for="customer in state.customers" :key="'order-customer-' + customer.id" :value="customer.id">
|
|
|
|
|
|
{{ customer.name }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">交付日期</label>
|
|
|
|
|
|
<input v-model="state.form.delivery_date" type="datetime-local" class="form-input" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">备注</label>
|
|
|
|
|
|
<input v-model="state.form.remark" class="form-input" placeholder="订单备注" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-header" style="margin-bottom: 8px;">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" @click="addSalesOrderItem">+ 添加成品</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-container">
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>成品</th>
|
|
|
|
|
|
<th>数量</th>
|
|
|
|
|
|
<th>单价</th>
|
|
|
|
|
|
<th>备注</th>
|
|
|
|
|
|
<th>操作</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="(line, index) in state.form.items" :key="'sales-order-line-' + index">
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<select v-model.number="line.product_id" class="form-input" required>
|
|
|
|
|
|
<option v-for="product in state.products.filter(p => p.item_type === 'finished')" :key="'sales-order-product-' + product.id" :value="product.id">
|
|
|
|
|
|
{{ product.sku }} - {{ product.name }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td><input v-model.number="line.quantity" type="number" min="1" class="form-input" required /></td>
|
|
|
|
|
|
<td><input v-model.number="line.unit_price" type="number" min="0" step="0.01" class="form-input" required /></td>
|
|
|
|
|
|
<td><input v-model="line.remark" class="form-input" placeholder="明细备注" /></td>
|
|
|
|
|
|
<td><button type="button" class="btn btn-sm btn-danger" @click="removeSalesOrderItem(index)">删除</button></td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">保存订单</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
<form v-else-if="state.modalType === 'purchaseOrder'" @submit.prevent="savePurchaseOrder">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">供应商 *</label>
|
|
|
|
|
|
<select v-model.number="state.form.supplier_id" class="form-input" required>
|
|
|
|
|
|
<option v-for="supplier in state.suppliers" :key="'purchase-supplier-' + supplier.id" :value="supplier.id">
|
|
|
|
|
|
{{ supplier.name }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">预计到货</label>
|
|
|
|
|
|
<input v-model="state.form.expected_date" type="datetime-local" class="form-input" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">备注</label>
|
|
|
|
|
|
<input v-model="state.form.remark" class="form-input" placeholder="采购单备注" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-header" style="margin-bottom: 8px;">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" @click="addPurchaseOrderItem">+ 添加物料</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-container">
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>物料</th>
|
|
|
|
|
|
<th>数量</th>
|
|
|
|
|
|
<th>单价</th>
|
|
|
|
|
|
<th>备注</th>
|
|
|
|
|
|
<th>操作</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="(line, index) in state.form.items" :key="'purchase-order-line-' + index">
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<select v-model.number="line.product_id" class="form-input" required>
|
|
|
|
|
|
<option v-for="material in state.materials" :key="'purchase-order-material-' + material.id" :value="material.id">
|
|
|
|
|
|
{{ material.sku }} - {{ material.name }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td><input v-model.number="line.quantity" type="number" min="1" class="form-input" required /></td>
|
|
|
|
|
|
<td><input v-model.number="line.unit_price" type="number" min="0" step="0.01" class="form-input" required /></td>
|
|
|
|
|
|
<td><input v-model="line.remark" class="form-input" placeholder="明细备注" /></td>
|
|
|
|
|
|
<td><button type="button" class="btn btn-sm btn-danger" @click="removePurchaseOrderItem(index)">删除</button></td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">保存采购单</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
<form v-else-if="state.modalType === 'purchaseReceive'" @submit.prevent="receivePurchaseOrder">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">入库仓库 *</label>
|
|
|
|
|
|
<select v-model.number="state.form.warehouse_id" class="form-input" required>
|
|
|
|
|
|
<option v-for="warehouse in state.warehouses" :key="'purchase-receive-warehouse-' + warehouse.id" :value="warehouse.id">
|
|
|
|
|
|
{{ warehouse.name }}{{ warehouse.is_default ? ' [默认]' : '' }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">备注</label>
|
|
|
|
|
|
<input v-model="state.form.remark" class="form-input" placeholder="到货说明" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-container">
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>物料</th>
|
|
|
|
|
|
<th>明细ID</th>
|
|
|
|
|
|
<th>剩余待入库</th>
|
|
|
|
|
|
<th>本次入库</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="line in state.purchaseReceiveItems" :key="'purchase-receive-line-' + line.item_id">
|
|
|
|
|
|
<td>{{ line.material_label }}</td>
|
|
|
|
|
|
<td>{{ line.item_id }}</td>
|
|
|
|
|
|
<td>{{ line.remaining_quantity }}</td>
|
|
|
|
|
|
<td><input v-model.number="line.receive_quantity" type="number" min="0" :max="line.remaining_quantity" class="form-input" required /></td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">确认入库</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<form v-else-if="state.modalType === 'productBom'" @submit.prevent="saveProductBom">
|
|
|
|
|
|
<div class="table-header" style="margin-bottom: 12px;">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" @click="addBomItem">+ 添加物料</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-container">
|
|
|
|
|
|
<table class="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>物料</th>
|
|
|
|
|
|
<th>数量</th>
|
|
|
|
|
|
<th>损耗率</th>
|
|
|
|
|
|
<th>操作</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="(item, idx) in state.productBomItems" :key="'bom-item-' + idx">
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<select v-model.number="item.material_id" class="form-input" required>
|
|
|
|
|
|
<option v-for="material in state.materials" :key="'bom-material-' + material.id" :value="material.id">
|
|
|
|
|
|
{{ material.sku }} - {{ material.name }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td><input v-model.number="item.quantity" type="number" min="0.0001" step="0.0001" class="form-input" required /></td>
|
|
|
|
|
|
<td><input v-model.number="item.loss_rate" type="number" min="0" step="0.0001" class="form-input" required /></td>
|
|
|
|
|
|
<td><button type="button" class="btn btn-sm btn-danger" @click="removeBomItem(idx)">删除</button></td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">保存BOM</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 供应商表单 -->
|
|
|
|
|
|
<form v-else-if="state.modalType === 'supplier'" @submit.prevent="saveSupplier">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">名称 *</label>
|
|
|
|
|
|
<input v-model="state.form.name" class="form-input" required placeholder="供应商名称" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">联系人</label>
|
|
|
|
|
|
<input v-model="state.form.contact_person" class="form-input" placeholder="联系人姓名" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">电话</label>
|
|
|
|
|
|
<input v-model="state.form.phone" class="form-input" placeholder="联系电话" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">邮箱</label>
|
|
|
|
|
|
<input v-model="state.form.email" type="email" class="form-input" placeholder="email@example.com" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">地址</label>
|
|
|
|
|
|
<input v-model="state.form.address" class="form-input" placeholder="详细地址" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">保存</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 客户表单 -->
|
|
|
|
|
|
<form v-else-if="state.modalType === 'customer'" @submit.prevent="saveCustomer">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">名称 *</label>
|
|
|
|
|
|
<input v-model="state.form.name" class="form-input" required placeholder="客户名称" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">联系人</label>
|
|
|
|
|
|
<input v-model="state.form.contact_person" class="form-input" placeholder="联系人姓名" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">电话</label>
|
|
|
|
|
|
<input v-model="state.form.phone" class="form-input" placeholder="联系电话" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">邮箱</label>
|
|
|
|
|
|
<input v-model="state.form.email" type="email" class="form-input" placeholder="email@example.com" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">地址</label>
|
|
|
|
|
|
<input v-model="state.form.address" class="form-input" placeholder="详细地址" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
|
|
|
|
|
<button type="submit" class="btn btn-primary">保存</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<form v-else-if="state.modalType === 'inventoryItem'" @submit.prevent="saveInventoryItem">
|
2026-03-15 13:50:23 +08:00
|
|
|
|
<div class="form-group">
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<label class="form-label">物料 *</label>
|
2026-03-15 22:04:28 +08:00
|
|
|
|
<select v-model.number="state.form.product_id" class="form-input" required>
|
2026-03-15 22:50:38 +08:00
|
|
|
|
<option v-if="!state.materials.length" :value="null" disabled>暂无物料,请先新增物料</option>
|
|
|
|
|
|
<option v-for="product in state.materials" :key="'stockin-product-' + product.id" :value="product.id">
|
2026-03-15 22:04:28 +08:00
|
|
|
|
{{ product.sku }} - {{ product.name }}(ID: {{ product.id }})
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
2026-03-15 22:04:28 +08:00
|
|
|
|
<label class="form-label">仓库 *</label>
|
|
|
|
|
|
<select v-model.number="state.form.warehouse_id" class="form-input" required>
|
2026-03-15 22:13:51 +08:00
|
|
|
|
<option v-if="!state.warehouses.length" :value="null" disabled>暂无仓库,系统将自动创建默认仓库</option>
|
2026-03-15 22:04:28 +08:00
|
|
|
|
<option v-for="warehouse in state.warehouses" :key="'stockin-warehouse-' + warehouse.id" :value="warehouse.id">
|
|
|
|
|
|
{{ warehouse.name }}{{ warehouse.code ? ' (' + warehouse.code + ')' : '' }}{{ warehouse.is_default ? ' [默认]' : '' }}(ID: {{ warehouse.id }})
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<label class="form-label">数量 *</label>
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<input v-model.number="state.form.quantity" type="number" class="form-input" required placeholder="库存数量" />
|
2026-03-15 13:50:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<label class="form-label">锁定数量</label>
|
|
|
|
|
|
<input v-model.number="state.form.locked_quantity" type="number" class="form-input" placeholder="锁定库存" />
|
2026-03-15 13:50:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<label class="form-label">批次号</label>
|
|
|
|
|
|
<input v-model="state.form.batch_number" class="form-input" placeholder="批次号(可选)" />
|
2026-03-15 13:50:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<label class="form-label">库位</label>
|
|
|
|
|
|
<input v-model="state.form.location" class="form-input" placeholder="库位(可选)" />
|
2026-03-15 13:50:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
2026-03-17 22:20:56 +08:00
|
|
|
|
<button type="submit" class="btn btn-primary" :disabled="!state.form.product_id || !state.form.warehouse_id">保存</button>
|
2026-03-15 13:50:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-17 00:12:36 +08:00
|
|
|
|
</div>
|
2026-03-04 00:47:41 +08:00
|
|
|
|
`
|
2026-02-17 00:12:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const routes = [
|
2026-03-04 00:47:41 +08:00
|
|
|
|
{ path: "/", component: HomeView },
|
|
|
|
|
|
{ path: "/login", component: LoginView },
|
|
|
|
|
|
{ path: "/users", component: UsersView },
|
|
|
|
|
|
{ path: "/moldinsight", component: MoldInsightView },
|
|
|
|
|
|
{ path: "/moldinsight/result/:taskId", component: ResultView },
|
|
|
|
|
|
{ path: "/inventory", component: InventoryView }
|
2026-02-17 00:12:36 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const router = createRouter({
|
|
|
|
|
|
history: createWebHistory(),
|
2026-03-04 00:47:41 +08:00
|
|
|
|
routes
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
router.beforeEach((to, from, next) => {
|
|
|
|
|
|
const publicPages = ['/login'];
|
|
|
|
|
|
const authRequired = !publicPages.includes(to.path);
|
|
|
|
|
|
|
|
|
|
|
|
if (authRequired && !appState.user) {
|
|
|
|
|
|
return next('/login');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (to.path === '/login' && appState.user) {
|
|
|
|
|
|
return next('/');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
next();
|
2026-02-17 00:12:36 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-04 00:10:05 +08:00
|
|
|
|
const app = createApp(App);
|
|
|
|
|
|
app.use(router);
|
|
|
|
|
|
app.mount("#app");
|