Files
geMoldInsight/static/vue-app.js
T
2026-03-26 22:13:16 +08:00

3999 lines
154 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Gemold - 模具制造管理系统
* 版本: 4.0.0
*/
const { createApp, ref, computed, onMounted, reactive, watch, nextTick } = Vue;
const { createRouter, createWebHistory, useRoute, useRouter } = VueRouter;
const appState = reactive({
user: null,
token: null,
loading: false,
notifications: [],
initialized: false
});
function formatFileSize(bytes) {
if (!bytes || bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function formatNumber(num) {
if (num === null || num === undefined) return "N/A";
if (num >= 1_000_000) return (num / 1_000_000).toFixed(2) + "M";
if (num >= 1_000) return (num / 1_000).toFixed(2) + "K";
return num.toFixed ? num.toFixed(2) : String(num);
}
function formatDateTime(dateString) {
if (!dateString) return "N/A";
try {
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}`;
} catch {
return dateString;
}
}
function formatDate(dateString) {
if (!dateString) return "N/A";
try {
return new Date(dateString).toLocaleDateString('zh-CN');
} catch {
return dateString;
}
}
function formatCurrency(amount) {
if (amount === null || amount === undefined) return "¥0.00";
return "¥" + Number(amount).toFixed(2);
}
let notificationId = 0;
const notificationDedup = new Map();
function addNotification(message, type = 'info') {
const dedupKey = `${type}:${message}`;
const now = Date.now();
const lastAt = notificationDedup.get(dedupKey) || 0;
if (now - lastAt < 2500) return;
notificationDedup.set(dedupKey, now);
const id = ++notificationId;
const notification = { id, message, type, timestamp: new Date(), visible: true };
appState.notifications.push(notification);
setTimeout(() => {
const index = appState.notifications.findIndex(n => n.id === id);
if (index > -1) {
appState.notifications[index].visible = false;
setTimeout(() => {
const idx = appState.notifications.findIndex(n => n.id === id);
if (idx > -1) appState.notifications.splice(idx, 1);
}, 300);
}
}, 5000);
}
function handleApiError(error, context = '') {
console.error(`API错误 [${context}]:`, error);
const message = error.message || '请求失败,请稍后重试';
addNotification(message, 'error');
return message;
}
async function parseErrorMessage(response) {
try {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const body = await response.json().catch(() => null);
if (body?.detail) {
if (Array.isArray(body.detail)) {
const lines = body.detail
.map((e) => {
const loc = Array.isArray(e?.loc) ? e.loc.join('.') : '';
const msg = e?.msg ? String(e.msg) : '校验失败';
return loc ? `${loc}: ${msg}` : msg;
})
.filter(Boolean);
return lines.length ? lines.join('\n') : '请求校验失败';
}
if (typeof body.detail === 'object') return JSON.stringify(body.detail);
return String(body.detail);
}
if (body?.message) return String(body.message);
return '请求失败';
}
const text = await response.text().catch(() => '');
const normalized = (text || '').trim();
if (!normalized) return '请求失败';
return normalized.length > 200 ? normalized.slice(0, 200) + '...' : normalized;
} catch {
return '请求失败';
}
}
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 message = await parseErrorMessage(response);
throw new Error(message);
}
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;
}
const App = {
setup() {
const route = useRoute();
const router = useRouter();
const menuItems = computed(() => {
const items = [
{ path: '/', label: '首页', icon: '⌂' },
{ path: '/inventory', label: '进销存', icon: '⊞' },
{ path: '/moldinsight', label: 'MoldInsight', icon: '◈' }
];
if (appState.user?.is_superuser) {
items.push({ path: '/users', label: '用户管理', icon: '👤' });
}
return items;
});
const isActive = (path) => {
if (path === '/') return route.path === '/';
return route.path.startsWith(path);
};
const handleLogout = async () => {
try {
await apiRequest('/api/auth/logout', { method: 'POST' });
} catch {}
clearAuth();
addNotification('已退出登录', 'success');
router.push('/login');
};
onMounted(() => {
initAuth();
});
const getPriorityText = (priority) => {
const priorityMap = {
'critical': '紧急',
'high': '高',
'medium': '中',
'low': '低'
};
return priorityMap[priority] || priority;
};
return {
route,
router,
appState,
menuItems,
isActive,
handleLogout,
getPriorityText,
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);
}
}
};
},
template: `
<div class="app-container">
<div class="notification-container" v-if="appState.notifications.length > 0">
<TransitionGroup name="notification">
<div
v-for="notification in appState.notifications"
:key="notification.id"
:class="['notification', 'notification-' + notification.type]"
>
<div class="notification-icon">
{{ notification.type === 'success' ? '✓' : notification.type === 'error' ? '✕' : notification.type === 'warning' ? '!' : 'i' }}
</div>
<div class="notification-content">
<div class="notification-message">{{ notification.message }}</div>
</div>
<button class="notification-close" @click="dismissNotification(notification.id)">×</button>
</div>
</TransitionGroup>
</div>
<header class="app-header">
<div class="header-content">
<div class="logo" @click="router.push('/')">
<div class="logo-icon">G</div>
<div>
<div class="logo-text">Gemold</div>
<div class="logo-subtitle">模具制造管理系统</div>
</div>
</div>
<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 }}
</router-link>
</nav>
<div class="user-section">
<template v-if="appState.user">
<div class="user-info">
<div class="user-avatar">{{ (appState.user.full_name || appState.user.username).charAt(0).toUpperCase() }}</div>
<span class="user-name">{{ appState.user.full_name || appState.user.username }}</span>
</div>
<button class="btn btn-secondary btn-sm" @click="handleLogout">退出</button>
</template>
<template v-else>
<router-link to="/login" class="btn btn-primary btn-sm">登录</router-link>
</template>
</div>
</div>
</header>
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
`,
};
const LoginView = {
setup() {
const router = useRouter();
const state = reactive({
username: '',
password: '',
loading: false,
error: '',
backendDbReady: true,
backendMessage: ''
});
onMounted(() => {
if (appState.user) {
router.push('/');
return;
}
fetch('/health')
.then(r => r.ok ? r.json().catch(() => null) : null)
.then(h => {
if (h && h.database_connected === false) {
state.backendDbReady = false;
state.backendMessage = '检测到数据库状态异常。你仍可直接尝试登录;若失败请检查当前服务实例与数据库连接。';
}
})
.catch(() => {
state.backendDbReady = false;
state.backendMessage = '健康检查请求失败。你仍可直接尝试登录;若失败请检查后端地址与网络。';
});
});
const enterDemoMode = () => {
saveAuth('demo', {
id: 0,
username: 'demo',
full_name: '演示用户',
is_superuser: false
});
addNotification('已进入演示模式', 'success');
router.push('/inventory');
};
const handleSubmit = async () => {
if (!state.username || !state.password) {
state.error = '请填写用户名和密码';
return;
}
state.loading = true;
state.error = '';
try {
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 message = await parseErrorMessage(res);
throw new Error(message || '登录失败');
}
const data = await res.json();
saveAuth(data.access_token, data.user);
addNotification('登录成功', 'success');
router.push('/');
} catch (e) {
state.error = e.message;
addNotification(e.message, 'error');
} finally {
state.loading = false;
}
};
return { state, handleSubmit, enterDemoMode };
},
template: `
<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>
</div>
<div v-if="!state.backendDbReady" class="inline-alert inline-alert-warning" style="margin-bottom: var(--space-5);">
<div class="inline-alert-title">提示</div>
<div class="inline-alert-message">{{ state.backendMessage }}</div>
</div>
<form @submit.prevent="handleSubmit" class="login-form">
<div class="form-group">
<label class="form-label">用户名</label>
<input
v-model="state.username"
type="text"
class="form-input"
placeholder="请输入用户名"
autocomplete="username"
/>
</div>
<div class="form-group">
<label class="form-label">密码</label>
<input
v-model="state.password"
type="password"
class="form-input"
placeholder="请输入密码"
autocomplete="current-password"
/>
</div>
<div v-if="state.error" class="form-error">{{ state.error }}</div>
<button type="submit" class="btn btn-primary btn-lg w-full" :disabled="state.loading">
{{ state.loading ? '登录中...' : '登录' }}
</button>
</form>
<button v-if="!state.backendDbReady" type="button" class="btn btn-secondary btn-lg w-full" style="margin-top: var(--space-3);" @click="enterDemoMode">
进入演示模式
</button>
<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>
</div>
</div>
</div>
`
};
const HomeView = {
setup() {
const router = useRouter();
const state = reactive({
stats: null,
loading: true,
backendDbReady: true
});
const loadStats = async () => {
try {
const health = await apiRequest('/health').catch(() => null);
state.backendDbReady = health?.database_connected !== false;
const inventoryStats = state.backendDbReady
? await apiRequest('/api/dashboard').catch(() => null)
: 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>
<p>正在加载数据...</p>
</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')">
<div class="stat-icon">⚙️</div>
<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')">
<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>
<button class="action-card" @click="$router.push('/inventory')">
<span class="action-icon">📤</span>
<span class="action-label">销售出库</span>
</button>
</div>
</div>
</div>
`
};
const UsersView = {
setup() {
const router = useRouter();
const state = reactive({
users: [],
roles: [],
loading: true,
showUserModal: false,
editingUser: null,
userForm: {
username: '',
email: '',
password: '',
full_name: '',
role_ids: []
}
});
const loadUsers = async () => {
try {
state.users = await apiRequest('/api/auth/users');
} catch (e) {
handleApiError(e, '加载用户列表');
} finally {
state.loading = false;
}
};
const loadRoles = async () => {
try {
state.roles = await apiRequest('/api/auth/roles');
} catch (e) {
handleApiError(e, '加载角色列表');
}
};
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;
}
try {
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();
} catch (e) {
handleApiError(e, '保存用户');
}
};
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 () => {
if (!appState.user?.is_superuser) {
router.push('/');
return;
}
await loadRoles();
loadUsers();
});
return { state, appState, openUserModal, saveUser, deleteUser, resetPassword, formatDateTime };
},
template: `
<div class="page-container">
<div class="page-header">
<div>
<h1>用户管理</h1>
<p>管理系统用户和权限</p>
</div>
<button class="btn-primary" @click="openUserModal()">+ 添加用户</button>
</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>
<span v-for="role in user.roles" :key="role" class="badge badge-info" style="margin-right: 4px;">
{{ role }}
</span>
</td>
<td>{{ formatDateTime(user.created_at) }}</td>
<td>
<div class="action-buttons">
<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>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<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>
</div>
`
};
const MoldInsightView = {
setup() {
const router = useRouter();
const deliveryDate = ref(null);
const expectedDate = ref(null);
const state = reactive({
selectedFile: null,
selectedMaterial: 'ABS',
moldParams: {
draftAngle: 2.0,
shrinkageRate: 0.5,
partingPrecision: 0.1,
cavityMatch: 95
},
uploading: false,
error: "",
currentTask: null,
polling: false,
dragOver: false,
progress: 0,
history: null,
expandedFiles: {}
});
const loadHistory = async () => {
try {
state.history = await apiRequest('/api/history');
} catch (e) {
console.error('加载历史记录失败:', e);
}
};
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, '加载文件历史');
}
}
};
const viewResult = (record) => {
router.push(`/moldinsight/result/${record.task_id}`);
};
const handleFileChange = (event) => {
const file = event.target.files[0];
if (!file) return;
validateAndSelectFile(file);
};
const validateAndSelectFile = (file) => {
if (!file.name.toLowerCase().endsWith(".stp") && !file.name.toLowerCase().endsWith(".step")) {
state.error = "请选择 STP 或 STEP 格式文件";
state.selectedFile = null;
return;
}
if (file.size > 100 * 1024 * 1024) {
state.error = "文件大小不能超过 100MB";
state.selectedFile = null;
return;
}
state.error = "";
state.selectedFile = file;
addNotification(`已选择文件: ${file.name}`, 'success');
};
const handleDrop = (event) => {
event.preventDefault();
state.dragOver = false;
const files = event.dataTransfer.files;
if (files.length > 0) validateAndSelectFile(files[0]);
};
const uploadFile = async () => {
if (!state.selectedFile) return;
state.uploading = true;
state.error = "";
state.progress = 0;
const formData = new FormData();
formData.append("file", state.selectedFile);
formData.append("material", state.selectedMaterial);
try {
const res = await fetch("/api/upload", {
method: "POST",
headers: appState.token ? { 'Authorization': `Bearer ${appState.token}` } : {},
body: formData
});
if (!res.ok) throw new Error(`上传失败: ${res.status}`);
const data = await res.json();
state.currentTask = { task_id: data.task_id, status: "processing", filename: data.file_info?.filename };
addNotification('文件上传成功,开始分析...', 'success');
startPolling(data.task_id);
} catch (e) {
state.error = handleApiError(e, '文件上传');
} finally {
state.uploading = false;
}
};
const startPolling = async (taskId) => {
console.log('[Polling] 开始轮询任务:', taskId);
state.polling = true;
state.progress = 10;
let pollCount = 0;
const poll = async () => {
try {
pollCount++;
state.progress = Math.min(90, 10 + pollCount * 0.5);
const task = await apiRequest(`/api/status/${taskId}`, { method: 'POST' });
state.currentTask = task;
state.task = task;
if (task.status === "completed") {
state.polling = false;
state.progress = 100;
addNotification('分析完成', 'success');
loadHistory();
router.push(`/moldinsight/result/${taskId}`);
return;
}
if (task.status === "failed") {
state.polling = false;
state.error = task.error || "分析失败";
addNotification('分析失败', 'error');
return;
}
if (pollCount < 300) setTimeout(poll, 2000);
} catch (e) {
state.polling = false;
state.error = handleApiError(e, '轮询状态');
}
};
poll();
};
onMounted(() => {
if (!appState.user) {
router.push('/login');
return;
}
loadHistory();
});
const isFoamMaterial = (material) => {
const foamMaterials = ['AlSi10Mg', 'AlSi12', 'Pure Al Foam', 'AlSi7Mg'];
return foamMaterials.includes(material);
};
return {
state,
handleFileChange,
handleDrop,
uploadFile,
formatFileSize,
formatDateTime,
formatNumber,
toggleFileHistory,
viewResult,
isFoamMaterial
};
},
template: `
<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>
</div>
<div v-if="state.selectedFile" class="file-info">
<span class="file-name">{{ state.selectedFile.name }}</span>
<span class="file-size">{{ formatFileSize(state.selectedFile.size) }}</span>
<button class="btn-clear" @click="state.selectedFile = null" title="清除文件">×</button>
</div>
<!-- 材料选择面板 -->
<div v-if="state.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>
</div>
<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>
</div>
</div>
<div v-if="state.history?.files?.length" class="section">
<h2 class="section-title">分析历史</h2>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>文件名</th>
<th>上传次数</th>
<th>文件大小</th>
<th>最新状态</th>
<th>最新分析时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<template v-for="file in state.history.files" :key="file.filename">
<tr>
<td>{{ file.filename }}</td>
<td>
<span class="badge badge-info">{{ file.upload_count }} 次</span>
</td>
<td>{{ formatFileSize(file.file_size) }}</td>
<td>
<span :class="['badge', file.latest_status === 'completed' ? 'badge-success' : file.latest_status === 'failed' ? 'badge-error' : 'badge-warning']">
{{ file.latest_status }}
</span>
</td>
<td>{{ formatDateTime(file.latest_upload_time) }}</td>
<td>
<div class="action-buttons">
<button v-if="file.latest_status === 'completed'" class="btn-sm btn-primary" @click="viewResult({task_id: file.latest_task_id})">
查看最新
</button>
<button class="btn-sm btn-icon" @click="toggleFileHistory(file.filename)" :title="state.expandedFiles[file.filename] ? '收起' : '展开历史记录'">
<span class="dropdown-icon" :class="{ 'expanded': state.expandedFiles[file.filename] }">▼</span>
</button>
</div>
</td>
</tr>
<tr v-if="state.expandedFiles[file.filename]" class="history-detail-row">
<td colspan="6">
<div class="history-dropdown">
<table class="data-table inner-table">
<thead>
<tr>
<th>上传时间</th>
<th>文件大小</th>
<th>状态</th>
<th>体积 (mm³)</th>
<th>表面积 (mm²)</th>
<th>重量 (g)</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="record in state.expandedFiles[file.filename]" :key="record.id">
<td>{{ formatDateTime(record.upload_time) }}</td>
<td>{{ formatFileSize(record.file_size) }}</td>
<td>
<span :class="['badge', record.status === 'completed' ? 'badge-success' : record.status === 'failed' ? 'badge-error' : 'badge-warning']">
{{ record.status }}
</span>
</td>
<td>{{ record.volume ? formatNumber(record.volume) : '-' }}</td>
<td>{{ record.surface_area ? formatNumber(record.surface_area) : '-' }}</td>
<td>{{ record.product_weight ? record.product_weight.toFixed(2) : '-' }}</td>
<td>
<button v-if="record.has_analysis" class="btn-sm btn-primary" @click="viewResult(record)">
查看详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
`
};
const ResultView = {
setup() {
const route = useRoute();
const router = useRouter();
const state = reactive({
task: null,
loading: true,
error: ''
});
const loadTask = async () => {
console.log('[ResultView] loadTask 开始, taskId:', route.params.taskId);
try {
state.task = await apiRequest(`/api/status/${route.params.taskId}`, { method: 'POST' });
console.log('任务数据:', state.task);
console.log('key_info:', state.task.key_info);
console.log('cavity_data:', state.task.cavity_data);
console.log('analysis_result:', state.task.analysis_result);
console.log('geometry_data:', state.task.geometry_data);
} catch (e) {
state.error = handleApiError(e, '加载任务详情');
} finally {
state.loading = false;
}
};
onMounted(() => {
if (!appState.user) {
router.push('/login');
return;
}
loadTask();
});
const getPriorityText = (priority) => {
const priorityMap = {
'critical': '紧急',
'high': '高',
'medium': '中',
'low': '低'
};
return priorityMap[priority] || priority;
};
const isFoamMaterial = (material) => {
return material === 'aluminum_foam';
};
return { state, formatFileSize, formatDateTime, formatNumber, getPriorityText, isFoamMaterial };
},
template: `
<div class="page-container">
<div class="page-header">
<button class="btn-back" @click="$router.back()">← 返回</button>
<h1>分析结果</h1>
</div>
<div v-if="state.loading" class="loading-state">
<div class="spinner"></div>
<span>加载中...</span>
</div>
<div v-else-if="state.error" class="error-state">
<p>{{ state.error }}</p>
</div>
<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>
</div>
<div class="info-item">
<span class="info-label">状态</span>
<span class="info-value">{{ state.task.status }}</span>
</div>
</div>
</div>
<div class="result-card">
<h3>几何数据</h3>
<div class="info-list">
<div class="info-item" v-if="state.task.mesh_summary">
<span class="info-label">顶点数</span>
<span class="info-value">{{ formatNumber(state.task.mesh_summary.vertex_count) }}</span>
</div>
<div class="info-item" v-if="state.task.mesh_summary">
<span class="info-label">面数</span>
<span class="info-value">{{ formatNumber(state.task.mesh_summary.face_count) }}</span>
</div>
<div class="info-item" v-if="state.task.mesh_summary || state.task.geometry_data">
<span class="info-label">边数</span>
<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>
</div>
</div>
</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>
</div>
<div v-if="state.task.analysis_result" class="viewer-section">
<h3>分析结果详情</h3>
<!-- 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>
<div class="verification-details" v-if="state.task.verification.comparison && (state.task.verification.comparison.volume || state.task.verification.comparison.surface_area)">
<div class="comparison-grid">
<div class="comparison-item" v-if="state.task.verification.comparison.volume">
<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>
<div class="comparison-item" v-if="state.task.verification.comparison.surface_area">
<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>
<!-- 分析摘要 -->
<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>
<div class="result-grid">
<div class="result-card full-width">
<h4>1. 产品特征识别</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">整体轮廓和尺寸比例</span>
<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>
</div>
<div class="info-item">
<span class="info-label">壁厚分布</span>
<span class="info-value">{{ state.task.analysis_result.detected_features?.find(f => f.feature_type === 'wall_thickness')?.description || '待分析' }}</span>
</div>
<div class="info-item">
<span class="info-label">加强筋位置和密度</span>
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === 'rib').length || 0 }} 个加强筋</span>
</div>
<div class="info-item">
<span class="info-label">孔洞和凹槽位置</span>
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === 'hole' || f.feature_type === 'pocket').length || 0 }} 个孔洞/凹槽</span>
</div>
<div class="info-item">
<span class="info-label">倒扣区域检测</span>
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === 'undercut').length || 0 }} 个倒扣区域</span>
</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 ?
'(' + (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>
</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>
<span class="info-value">{{ state.task.analysis_result.detected_features?.filter(f => f.feature_type === '定位').length || 0 }} 个定位特征</span>
</div>
<div class="info-item">
<span class="info-label">分型面选择</span>
<span class="info-value">基于产品几何:自动推荐最优分型面</span>
</div>
<!-- 设计建议列表 -->
<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>
</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>
<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>
</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>
<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>
</div>
</div>
</div>
</div>
</div>
</div>
`
};
const InventoryView = {
setup() {
const router = useRouter();
const route = useRoute();
const deliveryDateInput = ref(null);
const expectedDateInput = ref(null);
const deliveryDateNativeInput = ref(null);
const expectedDateNativeInput = ref(null);
const state = reactive({
activeTab: 'dashboard',
backendDbReady: true,
backendDbMessage: '',
productCategory: 'finished',
dashboard: null,
financeSummary: null,
financePeriod: {
year: new Date().getFullYear(),
quarter: ''
},
financeTransactions: [],
receivables: [],
payables: [],
customerFinanceStatement: [],
supplierFinanceStatement: [],
customerProductStatement: [],
supplierProductStatement: [],
products: [],
materials: [],
finishedProducts: [],
purchaseOrders: [],
purchaseWarehouseId: null,
purchaseReceiveItems: [],
productionOrders: [],
productionPlan: null,
productionWarehouseId: null,
suppliers: [],
customers: [],
warehouses: [],
inventory: [],
movements: [],
loading: false,
showModal: false,
modalType: '',
editingItem: null,
productBomItems: [],
form: {}
});
const parseDateTimeLocal = (text) => {
if (!text) return null;
const raw = String(text).trim();
const normalized = raw.replace('T', ' ').slice(0, 16);
const m = normalized.match(/^(\d{4})-(\d{2})-(\d{2})\s(\d{2}):(\d{2})$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
const hour = Number(m[4]);
const minute = Number(m[5]);
if (!Number.isFinite(year + month + day + hour + minute)) return null;
return new Date(year, month - 1, day, hour, minute, 0);
};
const toPickerValue = (value) => {
if (!value) return '';
const raw = String(value).trim();
if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}/.test(raw)) return raw.slice(0, 16);
if (raw.includes('T')) return raw.replace('T', ' ').slice(0, 16);
const dt = new Date(raw);
if (!Number.isFinite(dt.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
};
const toApiDateTime = (value) => {
if (!value) return null;
const text = String(value).trim();
// 如果是只有日期部分的格式 (yyyy-MM-dd)
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) {
return text; // 直接返回日期格式,后端 Pydantic 会自动处理
}
// 如果是带时间的格式,转换为 ISO 8601 格式
if (text.includes('T')) {
return text.length === 16 ? `${text}:00` : text;
}
// 将空格格式转换为 ISO 格式
const isoText = text.replace(' ', 'T');
return isoText.length === 16 ? `${isoText}:00` : isoText;
};
const toNativeValue = (value) => {
if (!value) return '';
const text = String(value).trim();
// 如果是只有日期部分的格式 (yyyy-MM-dd)
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) {
return text; // 直接返回日期格式
}
// 处理带时间的格式
const isoText = text.replace(' ', 'T');
return isoText.length >= 16 ? isoText.slice(0, 16) : isoText;
};
const fromNativeValue = (value) => {
if (!value) return '';
const text = String(value).trim();
// 如果是只有日期部分的格式 (yyyy-MM-dd)
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) {
return text; // 直接返回日期格式
}
// 处理带时间的格式
return text.replace('T', ' ').slice(0, 16);
};
let deliveryPicker = null;
let expectedPicker = null;
const destroyPickers = () => {
if (deliveryPicker) {
deliveryPicker.destroy();
deliveryPicker = null;
}
if (expectedPicker) {
expectedPicker.destroy();
expectedPicker = null;
}
};
const initPickers = () => {
destroyPickers();
if (typeof AirDatepicker !== 'function') return;
if (state.modalType === 'salesOrder' && deliveryDateInput.value) {
deliveryPicker = new AirDatepicker(deliveryDateInput.value, {
timepicker: false,
autoClose: true,
zIndex: 2005,
dateFormat: 'yyyy-MM-dd',
onSelect: ({ formattedDate }) => {
state.form.delivery_date = formattedDate || '';
state.form.delivery_date_native = toNativeValue(formattedDate || '');
}
});
const initial = parseDateTimeLocal(state.form.delivery_date);
if (initial) deliveryPicker.selectDate(initial, { silent: true });
}
if (state.modalType === 'purchaseOrder' && expectedDateInput.value) {
expectedPicker = new AirDatepicker(expectedDateInput.value, {
timepicker: false,
autoClose: true,
zIndex: 2005,
dateFormat: 'yyyy-MM-dd',
onSelect: ({ formattedDate }) => {
state.form.expected_date = formattedDate || '';
state.form.expected_date_native = toNativeValue(formattedDate || '');
}
});
const initial = parseDateTimeLocal(state.form.expected_date);
if (initial) expectedPicker.selectDate(initial, { silent: true });
}
};
const openDateTimePicker = (pickerKind) => {
if (pickerKind === 'delivery' && deliveryPicker) {
deliveryPicker.show();
return;
}
if (pickerKind === 'expected' && expectedPicker) {
expectedPicker.show();
return;
}
const nativeInput = pickerKind === 'delivery' ? deliveryDateNativeInput.value : expectedDateNativeInput.value;
if (!nativeInput) return;
if (typeof nativeInput.showPicker === 'function') {
nativeInput.showPicker();
return;
}
nativeInput.focus();
nativeInput.click();
};
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';
};
const loadDashboard = async () => {
state.loading = true;
try {
state.dashboard = await apiRequest('/api/dashboard');
} catch (e) {
handleApiError(e, '加载仪表盘');
} finally {
state.loading = false;
}
};
const loadFinishedProducts = async () => {
state.loading = true;
try {
state.finishedProducts = await apiRequest('/api/products?item_type=finished&limit=100');
} catch (e) {
handleApiError(e, '加载成品');
} finally {
state.loading = false;
}
};
const loadProducts = async () => {
// Compatibility wrapper if needed, or just load finished products
await loadFinishedProducts();
};
const loadMaterials = async () => {
state.loading = true;
try {
state.materials = await apiRequest('/api/products?item_type=material&limit=100');
} catch (e) {
handleApiError(e, '加载物料');
} finally {
state.loading = false;
}
};
const loadWarehouses = async () => {
state.loading = true;
try {
state.warehouses = await apiRequest('/api/warehouses');
} catch (e) {
handleApiError(e, '加载仓库');
} finally {
state.loading = false;
}
};
const ensureStockBaseData = async () => {
if (!state.materials.length) {
await loadMaterials();
}
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, '自动创建默认仓库');
}
}
};
const loadSuppliers = async () => {
state.loading = true;
try {
state.suppliers = await apiRequest('/api/suppliers');
} catch (e) {
handleApiError(e, '加载供应商');
} finally {
state.loading = false;
}
};
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;
}
};
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;
}
};
const loadCustomers = async () => {
state.loading = true;
try {
state.customers = await apiRequest('/api/customers');
} catch (e) {
handleApiError(e, '加载客户');
} finally {
state.loading = false;
}
};
const loadInventory = async () => {
state.loading = true;
try {
state.inventory = await apiRequest('/api/inventory');
} catch (e) {
handleApiError(e, '加载库存');
} finally {
state.loading = false;
}
};
const loadMovements = async () => {
state.loading = true;
try {
state.movements = await apiRequest('/api/stock-movements');
} catch (e) {
handleApiError(e, '加载变动记录');
} finally {
state.loading = false;
}
};
const loadFinance = async () => {
state.loading = true;
try {
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}`)
]);
state.financeSummary = summary;
state.financeTransactions = transactions;
state.receivables = receivables;
state.payables = payables;
state.customerFinanceStatement = customerStatement.items || [];
state.supplierFinanceStatement = supplierStatement.items || [];
state.customerProductStatement = customerProductStatement.items || [];
state.supplierProductStatement = supplierProductStatement.items || [];
} catch (e) {
handleApiError(e, '加载财务数据');
} finally {
state.loading = false;
}
};
const refreshFinanceByPeriod = () => {
if (state.activeTab === 'finance') {
loadFinance();
}
};
const switchTab = (tab) => {
state.activeTab = tab;
if (!state.backendDbReady) {
return;
}
switch (tab) {
case 'dashboard': loadDashboard(); break;
case 'sales_orders': loadProductionOrders(); break;
case 'products': loadFinishedProducts(); break;
case 'materials': loadMaterials(); break;
case 'purchases': loadPurchaseOrders(); break;
case 'inventory': loadInventory(); break;
case 'customers': loadCustomers(); break;
case 'suppliers': loadSuppliers(); break;
case 'finance': loadFinance(); break;
case 'movements': loadMovements(); break;
}
};
const menuGroups = [
{
key: 'overview',
title: '概览',
items: [
{ key: 'dashboard', label: '仪表盘' }
]
},
{
key: 'sales',
title: '销售',
items: [
{ key: 'sales_orders', label: '销售订单管理' }
]
},
{
key: 'purchase',
title: '采购',
items: [
{ key: 'purchases', label: '采购订单管理' }
]
},
{
key: 'product',
title: '产品',
items: [
{ key: 'products', label: '成品管理' },
{ key: 'materials', label: '物料管理' }
]
},
{
key: 'partner',
title: '往来单位',
items: [
{ key: 'customers', label: '客户管理' },
{ key: 'suppliers', label: '供应商管理' }
]
},
{
key: 'warehouse',
title: '仓库',
items: [
{ key: 'inventory', label: '库存管理' },
{ key: 'movements', label: '库存变动记录' }
]
},
{
key: 'finance',
title: '财务',
items: [
{ key: 'finance', label: '财务概览' }
]
}
];
const openGroups = reactive(
Object.fromEntries(menuGroups.map(g => [g.key, true]))
);
const toggleGroup = (groupKey) => {
openGroups[groupKey] = !openGroups[groupKey];
};
const activeMenu = computed(() => {
for (const group of menuGroups) {
const item = group.items.find(i => i.key === state.activeTab);
if (item) {
return { group, item };
}
}
return null;
});
const handleMenuClick = (itemKey) => {
switchTab(itemKey);
};
const switchProductCategory = (category) => {
state.productCategory = category;
};
const checkBackendHealth = async () => {
try {
const resp = await fetch('/health', { method: 'GET' });
if (!resp.ok) {
state.backendDbReady = false;
state.backendDbMessage = '后端服务异常,暂无法加载业务数据';
return;
}
const health = await resp.json().catch(() => null);
if (health && health.database_connected === false) {
state.backendDbReady = false;
state.backendDbMessage = '数据库未连接,当前仅可浏览界面,业务数据暂不可用';
return;
}
state.backendDbReady = true;
state.backendDbMessage = '';
} catch {
state.backendDbReady = false;
state.backendDbMessage = '无法连接后端服务';
}
};
const openCreateProduct = (itemType) => {
state.modalType = 'product';
state.editingItem = null;
state.form = {
item_type: itemType,
unit: itemType === 'material' ? 'kg' : '件',
min_stock: 0,
max_stock: 1000,
cost_price: 0,
sale_price: 0
};
state.showModal = true;
};
const openModal = async (type, item = null) => {
state.modalType = type;
state.editingItem = item;
if (item) {
if (type === 'salesOrder') {
// 检查订单状态,如果是已收款状态,则禁止编辑
if (item.status === 'paid') {
addNotification('已收款的销售订单禁止修改', 'warning');
state.modalType = null;
state.editingItem = null;
return;
}
await loadCustomers();
await loadFinishedProducts();
const detail = await apiRequest(`/api/sales-orders/${item.id}`);
state.form = {
customer_id: detail.customer_id,
delivery_date: toPickerValue(detail.delivery_date),
delivery_date_native: toNativeValue(toPickerValue(detail.delivery_date)),
remark: detail.remark || '',
items: (detail.items || []).map(line => ({
mode: line.product_id ? 'existing' : 'new',
product_id: line.product_id,
product_sku: '',
product_name: '',
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: toPickerValue(detail.expected_date),
expected_date_native: toNativeValue(toPickerValue(detail.expected_date)),
remark: detail.remark || '',
items: (detail.items || []).map(line => ({
product_id: line.product_id,
quantity: line.quantity,
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 };
}
} else {
state.form = {};
if (type === 'inventoryItem') {
await ensureStockBaseData();
state.form = {
product_id: state.materials[0]?.id || null,
warehouse_id: state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null,
quantity: 0,
locked_quantity: 0,
batch_number: '',
location: ''
};
}
if (type === 'salesOrder') {
state.form = {
customer_id: null,
delivery_date: '',
delivery_date_native: '',
remark: '',
items: []
};
}
if (type === 'purchaseOrder') {
state.form = {
supplier_id: null,
expected_date: '',
expected_date_native: '',
remark: '',
items: []
};
}
if (type === 'product') {
state.form = {
item_type: 'finished',
unit: '件',
min_stock: 0,
max_stock: 1000,
cost_price: 0,
sale_price: 0
};
}
if (type === 'salesOrder') {
await loadCustomers();
await loadFinishedProducts();
state.form = {
customer_id: state.customers[0]?.id || null,
delivery_date: '',
remark: '',
items: [{
mode: 'new',
product_id: null,
product_sku: '',
product_name: '',
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,
remark: ''
}]
};
}
}
state.showModal = true;
nextTick(() => {
initPickers();
});
};
const closeModal = () => {
state.showModal = false;
state.modalType = '';
state.editingItem = null;
state.productBomItems = [];
state.purchaseReceiveItems = [];
state.form = {};
destroyPickers();
};
const saveProduct = async () => {
try {
if (state.editingItem) {
await apiRequest(`/api/products/${state.editingItem.id}`, {
method: 'PUT',
body: JSON.stringify(state.form)
});
addNotification('产品更新成功', 'success');
} else {
await apiRequest('/api/products', {
method: 'POST',
body: JSON.stringify(state.form)
});
addNotification('产品创建成功', 'success');
}
closeModal();
loadProducts();
loadMaterials();
} catch (e) {
handleApiError(e, '保存产品');
}
};
const deleteProduct = async (id) => {
if (!confirm('确定要删除这个产品吗?')) return;
try {
await apiRequest(`/api/products/${id}`, { method: 'DELETE' });
addNotification('产品已删除', 'success');
loadProducts();
loadMaterials();
} catch (e) {
handleApiError(e, '删除产品');
}
};
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');
}
};
const saveSupplier = async () => {
try {
if (state.editingItem) {
await apiRequest(`/api/suppliers/${state.editingItem.id}`, {
method: 'PUT',
body: JSON.stringify(state.form)
});
addNotification('供应商更新成功', 'success');
} else {
await apiRequest('/api/suppliers', {
method: 'POST',
body: JSON.stringify(state.form)
});
addNotification('供应商创建成功', 'success');
}
closeModal();
loadSuppliers();
} catch (e) {
handleApiError(e, '保存供应商');
}
};
const deleteSupplier = async (id) => {
if (!confirm('确定要删除这个供应商吗?')) return;
try {
await apiRequest(`/api/suppliers/${id}`, { method: 'DELETE' });
addNotification('供应商已删除', 'success');
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)
});
addNotification('客户更新成功', 'success');
} else {
await apiRequest('/api/customers', {
method: 'POST',
body: JSON.stringify(state.form)
});
addNotification('客户创建成功', 'success');
}
closeModal();
loadCustomers();
} catch (e) {
handleApiError(e, '保存客户');
}
};
const deleteCustomer = async (id) => {
if (!confirm('确定要删除这个客户吗?')) return;
try {
await apiRequest(`/api/customers/${id}`, { method: 'DELETE' });
addNotification('客户已删除', 'success');
loadCustomers();
} catch (e) {
handleApiError(e, '删除客户');
}
};
const saveInventoryItem = async () => {
try {
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({
mode: 'new',
product_id: null,
product_sku: '',
product_name: '',
quantity: 1,
unit_price: 0,
remark: ''
});
};
const setSalesOrderLineMode = (line, mode) => {
line.mode = mode;
if (mode === 'new') {
line.product_id = null;
line.product_sku = '';
line.product_name = '';
} else {
line.product_sku = '';
line.product_name = '';
if (!line.product_id) {
line.product_id = state.finishedProducts[0]?.id || null;
}
}
};
const removeSalesOrderItem = (index) => {
state.form.items.splice(index, 1);
};
const saveSalesOrder = async () => {
try {
if (!state.form.customer_id) {
addNotification('请选择客户', 'warning');
return;
}
if (!state.form.items || !state.form.items.length) {
addNotification('请至少添加一个成品明细', 'warning');
return;
}
for (const [i, item] of state.form.items.entries()) {
const idx = i + 1;
if (item.mode === 'existing') {
if (!item.product_id) {
addNotification(`第 ${idx} 行:请选择模具`, 'warning');
return;
}
} else {
if (!item.product_sku || !String(item.product_sku).trim()) {
addNotification(`第 ${idx} 行:请输入模具SKU`, 'warning');
return;
}
if (!item.product_name || !String(item.product_name).trim()) {
addNotification(`第 ${idx} 行:请输入模具名称`, 'warning');
return;
}
}
if (!Number.isFinite(item.quantity) || item.quantity <= 0) {
addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning');
return;
}
if (!Number.isFinite(item.unit_price) || item.unit_price < 0) {
addNotification(`第 ${idx} 行:单价必须大于等于 0`, 'warning');
return;
}
}
const payload = {
customer_id: state.form.customer_id,
delivery_date: toApiDateTime(state.form.delivery_date),
remark: state.form.remark,
items: state.form.items.map(item => ({
product_id: item.mode === 'existing' ? (item.product_id || null) : null,
product_sku: item.mode === 'existing' ? null : (item.product_sku || null),
product_name: item.mode === 'existing' ? null : (item.product_name || null),
product_category: null,
product_unit: '件',
quantity: item.quantity,
unit_price: item.unit_price,
remark: item.remark || ''
}))
};
if (state.editingItem) {
const result = await apiRequest(`/api/sales-orders/${state.editingItem.id}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
addNotification(result.production_status === 'bom_missing' ? '订单已保存,但成品未配置BOM,未扣减物料' : '销售订单更新成功并已自动扣减物料', result.production_status === 'bom_missing' ? 'warning' : 'success');
} else {
const result = await apiRequest('/api/sales-orders', {
method: 'POST',
body: JSON.stringify(payload)
});
addNotification(result.production_status === 'bom_missing' ? '订单已创建,但成品未配置BOM,未扣减物料' : '销售订单创建成功并已自动扣减物料', result.production_status === 'bom_missing' ? 'warning' : 'success');
}
closeModal();
loadProductionOrders();
loadFinishedProducts();
loadInventory();
loadMovements();
} catch (e) {
handleApiError(e, '保存销售订单');
}
};
const updateSalesOrderStatus = async (order, targetStatus) => {
try {
await apiRequest(`/api/sales-orders/${order.id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status: targetStatus })
});
addNotification('订单状态已更新', 'success');
loadProductionOrders();
} catch (e) {
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,
remark: ''
});
};
const removePurchaseOrderItem = (index) => {
state.form.items.splice(index, 1);
};
const savePurchaseOrder = async () => {
try {
if (!state.form.supplier_id) {
addNotification('请选择供应商', 'warning');
return;
}
if (!state.form.items || !state.form.items.length) {
addNotification('请至少添加一个物料明细', 'warning');
return;
}
for (const [i, item] of state.form.items.entries()) {
const idx = i + 1;
if (!item.product_id) {
addNotification(`第 ${idx} 行:请选择物料`, 'warning');
return;
}
if (!Number.isFinite(item.quantity) || item.quantity <= 0) {
addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning');
return;
}
}
const payload = {
supplier_id: state.form.supplier_id,
expected_date: toApiDateTime(state.form.expected_date),
remark: state.form.remark,
items: state.form.items.map(item => ({
product_id: item.product_id,
quantity: item.quantity,
remark: item.remark
}))
};
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, '保存采购订单');
}
};
const deletePurchaseOrder = async (orderId) => {
if (!confirm('确定删除这个采购订单吗?')) return;
try {
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`, {
method: 'POST',
body: JSON.stringify({
warehouse_id: state.form.warehouse_id || state.purchaseWarehouseId,
items,
remark: state.form.remark || ''
})
});
addNotification('采购到货入库成功', 'success');
closeModal();
loadPurchaseOrders();
loadInventory();
loadMovements();
} catch (e) {
handleApiError(e, '采购到货入库');
}
};
onMounted(() => {
if (!appState.user) {
router.push('/login');
return;
}
checkBackendHealth().then(() => {
if (!state.backendDbReady) {
addNotification(state.backendDbMessage || '业务服务不可用', 'warning');
return;
}
loadDashboard();
});
});
return {
state,
switchTab,
menuGroups,
openGroups,
toggleGroup,
activeMenu,
handleMenuClick,
formatNumber,
formatCurrency,
formatDateTime,
formatDate,
openModal,
switchProductCategory,
openCreateProduct,
closeModal,
deliveryDateInput,
expectedDateInput,
openDateTimePicker,
saveProduct,
deleteProduct,
editProductBom,
addBomItem,
removeBomItem,
saveProductBom,
saveSupplier,
deleteSupplier,
saveCustomer,
deleteCustomer,
saveInventoryItem,
deleteInventoryItem,
addSalesOrderItem,
removeSalesOrderItem,
setSalesOrderLineMode,
saveSalesOrder,
deleteSalesOrder,
updateSalesOrderStatus,
addPurchaseOrderItem,
removePurchaseOrderItem,
savePurchaseOrder,
deletePurchaseOrder,
receivePurchaseOrder,
loadPurchaseOrders,
loadProductionOrders,
refreshFinanceByPeriod,
getMovementTypeLabel,
getMovementBadgeClass
};
},
template: `
<div class="page-container">
<div class="page-header">
<h1>进销存管理</h1>
<p>库存、采购、销售管理</p>
</div>
<div class="inventory-layout">
<aside class="inventory-sidebar">
<div class="inventory-menu">
<div v-for="group in menuGroups" :key="group.key" class="inventory-menu-group">
<button type="button" class="inventory-menu-title" @click="toggleGroup(group.key)">
<span>{{ group.title }}</span>
<span class="inventory-menu-caret">{{ openGroups[group.key] ? '▾' : '▸' }}</span>
</button>
<div v-show="openGroups[group.key]" class="inventory-menu-items">
<button
v-for="item in group.items"
:key="group.key + '-' + item.key"
type="button"
:class="['inventory-menu-item', { active: state.activeTab === item.key }]"
@click="handleMenuClick(item.key)"
>
{{ item.label }}
</button>
</div>
</div>
</div>
</aside>
<section class="inventory-content">
<div class="inventory-content-header">
<div class="inventory-content-title">
<span class="inventory-breadcrumb">{{ activeMenu?.group?.title || '进销存' }}</span>
<span class="inventory-breadcrumb-sep">/</span>
<span class="inventory-breadcrumb-current">{{ activeMenu?.item?.label || '' }}</span>
</div>
</div>
<div v-if="!state.backendDbReady" class="inline-alert inline-alert-warning">
<div class="inline-alert-title">提示</div>
<div class="inline-alert-message">{{ state.backendDbMessage }}</div>
</div>
<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>
<div class="stat-label">成品数量</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📊</div>
<div class="stat-content">
<div class="stat-value">{{ state.dashboard?.total_stock || 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.dashboard?.total_value || 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">{{ state.dashboard?.supplier_count || 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">{{ state.dashboard?.customer_count || 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">{{ state.dashboard?.warehouse_count || 0 }}</div>
<div class="stat-label">仓库</div>
</div>
</div>
</div>
<div v-else-if="state.activeTab === 'products'">
<div class="table-header">
<button class="btn btn-primary" @click="openCreateProduct('finished')">+ 新增成品</button>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>SKU</th>
<th>名称</th>
<th>分类</th>
<th>单位</th>
<th>成本价</th>
<th>销售价</th>
<th>基础物料成本</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="product in state.finishedProducts" :key="product.id">
<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>
<td>{{ formatCurrency(product.material_cost || 0) }}</td>
<td>
<div class="action-btns">
<button class="btn btn-sm btn-secondary" @click="editProductBom(product)">BOM</button>
<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>
</div>
<div v-else-if="state.activeTab === 'materials'">
<div class="table-header">
<button class="btn btn-primary" @click="openCreateProduct('material')">+ 新增物料</button>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>SKU</th>
<th>名称</th>
<th>分类</th>
<th>单位</th>
<th>成本价</th>
<th>最低库存</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="product in state.materials" :key="product.id">
<td>{{ product.sku }}</td>
<td>{{ product.name }}</td>
<td>{{ product.category || '-' }}</td>
<td>{{ product.unit }}</td>
<td>{{ formatCurrency(product.cost_price) }}</td>
<td>{{ product.min_stock || '-' }}</td>
<td>
<div class="action-btns">
<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>
</div>
<div v-else-if="state.activeTab === 'inventory'">
<div class="table-header">
<button class="btn btn-primary" @click="openModal('inventoryItem')">+ 新增物料库存</button>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>SKU</th>
<th>物料</th>
<th>仓库</th>
<th>数量</th>
<th>可用</th>
<th>操作</th>
</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>
<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>
<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>{{ order.expected_date ? formatDate(order.expected_date) : '-' }}</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>
</tr>
</tbody>
</table>
</div>
</div>
<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>
</div>
<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>
</div>
<div v-else-if="state.activeTab === 'sales_orders'">
<div class="table-header" style="margin-bottom: 12px;">
<button class="btn btn-primary" @click="openModal('salesOrder')">+ 新增销售订单</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.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>
<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>{{ formatCurrency(order.total_amount || 0) }}</td>
<td>{{ order.delivery_date ? formatDate(order.delivery_date) : '-' }}</td>
<td>{{ order.created_at ? formatDateTime(order.created_at) : '-' }}</td>
<td>{{ order.actual_delivery_date ? formatDateTime(order.actual_delivery_date) : '-' }}</td>
<td>{{ order.actual_payment_date ? formatDateTime(order.actual_payment_date) : '-' }}</td>
<td>{{ order.status === 'manufacturing' ? '制造中' : order.status === 'delivered' ? '已交付' : order.status === 'paid' ? '已收款' : order.status }}</td>
<td>
<div class="action-btns">
<div class="dropdown">
<button class="btn btn-sm btn-secondary dropdown-toggle">状态</button>
<div class="dropdown-menu">
<button class="dropdown-item" @click="updateSalesOrderStatus(order, 'delivered')">已交付</button>
<button class="dropdown-item" @click="updateSalesOrderStatus(order, 'paid')">已收款</button>
</div>
</div>
<button class="btn btn-sm btn-secondary" @click="openModal('salesOrder', order)" :disabled="order.status === 'paid'" :class="{'btn-disabled': order.status === 'paid'}">编辑</button>
<button class="btn btn-sm btn-danger" @click="deleteSalesOrder(order.id)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="state.activeTab === 'finance'">
<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>
<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">
<div class="stat-value">{{ formatCurrency(state.financeSummary?.period_receipt_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?.period_payment_total || 0) }}</div>
<div class="stat-label">周期付款</div>
</div>
</div>
</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.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>
<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>
<div v-else-if="state.activeTab === 'movements'" 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="movement in state.movements" :key="movement.id">
<td>{{ movement.product_name }}{{ movement.product_sku ? ' (' + movement.product_sku + ')' : '' }}</td>
<td>
<span :class="['badge', getMovementBadgeClass(movement.movement_type)]">
{{ getMovementTypeLabel(movement.movement_type) }}
</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>
</div>
</section>
</div>
<!-- 模态框 -->
<div v-if="state.showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<div class="modal-header">
<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>
<button class="modal-close" @click="closeModal">&times;</button>
</div>
<div class="modal-body">
<!-- 产品表单 -->
<form v-if="state.modalType === 'product'" @submit.prevent="saveProduct">
<div class="form-group">
<label class="form-label">类型 *</label>
<input class="form-input" :value="state.form.item_type === 'material' ? '物料(纳入库存)' : '成品(按单生产,不做库存)'" disabled />
<input v-model="state.form.item_type" type="hidden" />
</div>
<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>
<div v-if="state.form.item_type === 'material'" class="form-group">
<label class="form-label">最低库存</label>
<input v-model.number="state.form.min_stock" type="number" class="form-input" placeholder="0" />
</div>
<div v-if="state.form.item_type === 'finished'" class="form-group">
<label class="form-label">说明</label>
<input disabled value="成品不做库存,成本由下方BOM定义物料构成后自动计算" class="form-input" />
</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 === '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>
<div class="datetime-field" @click="openDateTimePicker('delivery')">
<input ref="deliveryDateInput" v-model="state.form.delivery_date" type="text" class="form-input air-datetime-input" readonly placeholder="选择日期时间" />
<input ref="deliveryDateNativeInput" v-model="state.form.delivery_date_native" @change="state.form.delivery_date = fromNativeValue(state.form.delivery_date_native)" type="datetime-local" class="datetime-native" />
<span class="datetime-field-icon" aria-hidden="true">📅</span>
</div>
</div>
<div class="form-group">
<label class="form-label">备注</label>
<input v-model="state.form.remark" class="form-input" placeholder="订单备注" />
</div>
<div class="form-group" style="margin-bottom: var(--space-4);">
<button type="button" class="btn btn-secondary" @click="addSalesOrderItem">+ 添加模具</button>
</div>
<div v-if="state.form.items.length === 0" class="empty-state">
<div class="empty-icon">📦</div>
<div class="empty-title">暂无模具</div>
<div class="empty-desc">请点击"添加模具"按钮添加订单明细</div>
</div>
<div v-else class="sales-order-items">
<div v-for="(line, index) in state.form.items" :key="'sales-order-line-' + index" class="sales-order-item">
<div class="sales-order-item-header">
<button type="button" :class="['btn', line.mode === 'new' ? 'active' : '']" @click="setSalesOrderLineMode(line, 'new')">新模</button>
<button type="button" :class="['btn', line.mode === 'existing' ? 'active' : '']" @click="setSalesOrderLineMode(line, 'existing')">改模</button>
</div>
<div class="sales-order-item-content">
<div v-if="line.mode !== 'existing'">
<div class="form-group">
<label class="form-label">模具SKU *</label>
<input v-model="line.product_sku" class="form-input" placeholder="请输入模具SKU" required />
</div>
<div class="form-group">
<label class="form-label">模具名称 *</label>
<input v-model="line.product_name" class="form-input" placeholder="请输入模具名称" required />
</div>
</div>
<div v-else>
<div class="form-group">
<label class="form-label">选择模具 *</label>
<select v-model.number="line.product_id" class="form-input" required>
<option value="">请选择模具</option>
<option v-for="product in state.finishedProducts" :key="'sales-order-product-' + product.id" :value="product.id">
{{ product.sku }} - {{ product.name }}
</option>
</select>
</div>
</div>
<div class="sales-order-item-row">
<div class="sales-order-item-column">
<div class="form-group">
<label class="form-label">数量 *</label>
<input v-model.number="line.quantity" type="number" min="1" class="form-input" placeholder="请输入数量" required />
</div>
</div>
<div class="sales-order-item-column">
<div class="form-group">
<label class="form-label">单价 *</label>
<input v-model.number="line.unit_price" type="number" min="0" step="0.01" class="form-input" placeholder="请输入单价" required />
</div>
</div>
<div class="sales-order-item-column">
<div class="form-group">
<label class="form-label">备注</label>
<input v-model="line.remark" class="form-input" placeholder="请输入备注" />
</div>
</div>
<div class="sales-order-item-actions">
<button type="button" class="btn btn-sm btn-danger" @click="removeSalesOrderItem(index)">删除</button>
</div>
</div>
</div>
</div>
</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>
<div class="datetime-field" @click="openDateTimePicker('expected')">
<input ref="expectedDateInput" v-model="state.form.expected_date" type="text" class="form-input air-datetime-input" readonly placeholder="选择日期时间" />
<input ref="expectedDateNativeInput" v-model="state.form.expected_date_native" @change="state.form.expected_date = fromNativeValue(state.form.expected_date_native)" type="datetime-local" class="datetime-native" />
<span class="datetime-field-icon" aria-hidden="true">📅</span>
</div>
</div>
<div class="form-group">
<label class="form-label">备注</label>
<input v-model="state.form.remark" class="form-input" placeholder="采购单备注" />
</div>
<div class="form-group" style="margin-bottom: var(--space-4);">
<button type="button" class="btn btn-secondary" @click="addPurchaseOrderItem">+ 添加物料</button>
</div>
<div v-if="state.form.items.length === 0" class="empty-state">
<div class="empty-icon">📦</div>
<div class="empty-title">暂无物料</div>
<div class="empty-desc">请点击"添加物料"按钮添加采购明细</div>
</div>
<div v-else class="purchase-order-items">
<div v-for="(line, index) in state.form.items" :key="'purchase-order-line-' + index" class="purchase-order-item">
<div class="purchase-order-item-content">
<div class="form-group">
<label class="form-label">物料 *</label>
<select v-model.number="line.product_id" class="form-input" required>
<option value="">请选择物料</option>
<option v-for="material in state.materials" :key="'purchase-order-material-' + material.id" :value="material.id">
{{ material.sku }} - {{ material.name }}
</option>
</select>
</div>
<div class="purchase-order-item-row">
<div class="purchase-order-item-column">
<div class="form-group">
<label class="form-label">数量 *</label>
<input v-model.number="line.quantity" type="number" min="1" class="form-input" placeholder="请输入数量" required />
</div>
</div>
<div class="purchase-order-item-column">
<div class="form-group">
<label class="form-label">单价</label>
<div class="form-input" style="padding: 8px 12px; background: var(--bg-secondary);">
¥{{ (state.materials.find(m => m.id === line.product_id)?.cost_price || 0).toFixed(2) }}
</div>
</div>
</div>
<div class="purchase-order-item-column">
<div class="form-group">
<label class="form-label">总价</label>
<div class="form-input" style="padding: 8px 12px; background: var(--bg-secondary);">
¥{{ ((state.materials.find(m => m.id === line.product_id)?.cost_price || 0) * (line.quantity || 0)).toFixed(2) }}
</div>
</div>
</div>
<div class="purchase-order-item-column">
<div class="form-group">
<label class="form-label">备注</label>
<input v-model="line.remark" class="form-input" placeholder="请输入备注" />
</div>
</div>
<div class="purchase-order-item-actions">
<button type="button" class="btn btn-sm btn-danger" @click="removePurchaseOrderItem(index)">删除</button>
</div>
</div>
</div>
</div>
</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>
<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>
<!-- 供应商表单 -->
<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>
<form v-else-if="state.modalType === 'inventoryItem'" @submit.prevent="saveInventoryItem">
<div class="form-group">
<label class="form-label">物料 *</label>
<select v-model.number="state.form.product_id" class="form-input" required>
<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">
{{ product.sku }} - {{ product.name }}(ID: {{ product.id }})
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">仓库 *</label>
<select v-model.number="state.form.warehouse_id" class="form-input" required>
<option v-if="!state.warehouses.length" :value="null" disabled>暂无仓库,系统将自动创建默认仓库</option>
<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>
</div>
<div class="form-group">
<label class="form-label">数量 *</label>
<input v-model.number="state.form.quantity" type="number" class="form-input" required placeholder="库存数量" />
</div>
<div class="form-group">
<label class="form-label">锁定数量</label>
<input v-model.number="state.form.locked_quantity" type="number" class="form-input" placeholder="锁定库存" />
</div>
<div class="form-group">
<label class="form-label">批次号</label>
<input v-model="state.form.batch_number" class="form-input" placeholder="批次号(可选)" />
</div>
<div class="form-group">
<label class="form-label">库位</label>
<input v-model="state.form.location" 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" :disabled="!state.form.product_id || !state.form.warehouse_id">保存</button>
</div>
</form>
</div>
</div>
</div>
</div>
`
};
const DesignSystemView = {
setup() {
const state = reactive({
ui: document.documentElement.dataset.ui || 'v1',
theme: document.documentElement.dataset.theme || 'light'
});
const tokensPreview = computed(() => {
const styles = getComputedStyle(document.documentElement);
const keys = [
'--bg-primary',
'--bg-secondary',
'--text-primary',
'--text-secondary',
'--border-default',
'--primary-500',
'--primary-600',
'--success-500',
'--warning-500',
'--danger-500',
'--radius-md',
'--radius-lg',
'--shadow-sm',
'--shadow-md',
'--duration-fast',
'--duration-normal'
];
return keys
.map(k => ({ key: k, value: styles.getPropertyValue(k).trim() }))
.filter(item => item.value);
});
const toggleTheme = () => {
const next = state.theme === 'dark' ? 'light' : 'dark';
state.theme = next;
document.documentElement.dataset.theme = next;
try { localStorage.setItem('gemold_theme', next); } catch (e) {}
};
const toggleUi = () => {
const next = state.ui === 'v2' ? 'v1' : 'v2';
state.ui = next;
document.documentElement.dataset.ui = next;
try { localStorage.setItem('gemold_ui_version', next); } catch (e) {}
};
return { state, tokensPreview, toggleTheme, toggleUi };
},
template: `
<div class="page-container">
<div class="page-header">
<h1>设计体系</h1>
<p>设计令牌、组件状态与无障碍规范预览</p>
</div>
<div class="card" style="margin-bottom: var(--space-6);">
<div class="card-header" style="display:flex; align-items:center; justify-content:space-between; gap: var(--space-4);">
<div>
<div class="card-title">预览开关</div>
<div class="card-subtitle">当前 UI: {{ state.ui }} · 主题: {{ state.theme }}</div>
</div>
<div class="action-btns">
<button type="button" class="btn btn-secondary" @click="toggleTheme">切换主题</button>
<button type="button" class="btn btn-primary" @click="toggleUi">切换 UI 版本</button>
</div>
</div>
</div>
<div class="card" style="margin-bottom: var(--space-6);">
<div class="card-header">
<div class="card-title">Design Tokens(运行时)</div>
<div class="card-subtitle">来自 CSS Variables,作为组件与页面的单一事实源</div>
</div>
<div class="card-body">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Token</th>
<th>Value</th>
<th>Preview</th>
</tr>
</thead>
<tbody>
<tr v-for="t in tokensPreview" :key="t.key">
<td><code>{{ t.key }}</code></td>
<td><code>{{ t.value }}</code></td>
<td>
<span v-if="t.value.startsWith('#')" :style="{display:'inline-block', width:'24px', height:'16px', borderRadius:'6px', background:t.value, border:'1px solid var(--border-default)'}"></span>
<span v-else>-</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">组件状态样例</div>
<div class="card-subtitle">检查 hover/focus/disabled/loading 等一致性与可见焦点</div>
</div>
<div class="card-body" style="display:flex; flex-wrap:wrap; gap: var(--space-3);">
<button type="button" class="btn btn-primary">Primary</button>
<button type="button" class="btn btn-secondary">Secondary</button>
<button type="button" class="btn btn-danger">Danger</button>
<button type="button" class="btn btn-primary" disabled>Disabled</button>
<input class="form-input" placeholder="输入框" style="max-width:260px;" />
<input class="form-input" aria-invalid="true" placeholder="Invalid" style="max-width:260px;" />
</div>
</div>
</div>
`
};
const ReleaseView = {
setup() {
const state = reactive({
ui: document.documentElement.dataset.ui || 'v1',
theme: document.documentElement.dataset.theme || 'light',
percent: 0,
uid: ''
});
const load = () => {
try { state.uid = localStorage.getItem('gemold_uid') || ''; } catch (e) { state.uid = ''; }
try {
const p = Number(localStorage.getItem('gemold_ui_rollout_percent') || 0);
state.percent = Number.isFinite(p) ? p : 0;
} catch (e) {
state.percent = 0;
}
};
const setUi = (v) => {
state.ui = v;
try { localStorage.setItem('gemold_ui_version', v); } catch (e) {}
document.documentElement.dataset.ui = v;
addNotification(`已切换 UI 到 ${v}(刷新后完全生效)`, 'success');
};
const setTheme = (v) => {
state.theme = v;
try { localStorage.setItem('gemold_theme', v); } catch (e) {}
document.documentElement.dataset.theme = v;
addNotification(`已切换主题到 ${v}(刷新后完全生效)`, 'success');
};
const savePercent = () => {
var p = Number(state.percent);
if (!Number.isFinite(p) || p < 0) p = 0;
if (p > 100) p = 100;
state.percent = p;
try { localStorage.setItem('gemold_ui_rollout_percent', String(p)); } catch (e) {}
addNotification(`已设置灰度比例为 ${p}%(新用户分桶生效)`, 'success');
};
const rollbackToV1 = () => {
try { localStorage.setItem('gemold_ui_version', 'v1'); } catch (e) {}
try { localStorage.setItem('gemold_ui_rollout_percent', '0'); } catch (e) {}
document.documentElement.dataset.ui = 'v1';
state.ui = 'v1';
state.percent = 0;
addNotification('已回滚到 v1(建议刷新页面确认)', 'warning');
};
onMounted(load);
return { state, setUi, setTheme, savePercent, rollbackToV1 };
},
template: `
<div class="page-container">
<div class="page-header">
<h1>灰度发布与回滚</h1>
<p>本页仅用于内部控制 UI 灰度与快速回退</p>
</div>
<div class="card" style="margin-bottom: var(--space-6);">
<div class="card-header">
<div class="card-title">当前状态</div>
<div class="card-subtitle">UI: {{ state.ui }} · Theme: {{ state.theme }} · UID: {{ state.uid || '-' }}</div>
</div>
<div class="card-body" style="display:flex; flex-wrap:wrap; gap: var(--space-3);">
<button type="button" class="btn btn-secondary" @click="setUi('v1')">切到 v1</button>
<button type="button" class="btn btn-primary" @click="setUi('v2')">切到 v2</button>
<button type="button" class="btn btn-secondary" @click="setTheme('light')">浅色</button>
<button type="button" class="btn btn-secondary" @click="setTheme('dark')">深色</button>
<button type="button" class="btn btn-danger" @click="rollbackToV1">一键回滚</button>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">灰度比例</div>
<div class="card-subtitle">用于无后端场景的本地灰度演练;生产建议由后端/网关下发</div>
</div>
<div class="card-body" style="display:flex; align-items:end; gap: var(--space-4); flex-wrap:wrap;">
<div class="form-group" style="margin:0; min-width: 240px;">
<label class="form-label">UI v2 灰度百分比(0-100)</label>
<input v-model.number="state.percent" type="number" min="0" max="100" class="form-input" />
</div>
<button type="button" class="btn btn-primary" @click="savePercent">保存</button>
</div>
</div>
</div>
`
};
const routes = [
{ 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 },
{ path: "/_design-system", component: DesignSystemView },
{ path: "/_release", component: ReleaseView }
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to, from, next) => {
const publicPages = ['/login', '/_design-system', '/_release'];
const authRequired = !publicPages.includes(to.path);
if (authRequired && !appState.user) {
return next('/login');
}
if (to.path === '/login' && appState.user) {
return next('/');
}
next();
});
const app = createApp(App);
app.use(router);
app.mount("#app");