Files
geMoldInsight/static/vue-app.js
T
2026-05-29 10:11:28 +08:00

5283 lines
206 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" @click="handleLogout">退出</button>
</template>
<template v-else>
<router-link to="/login" class="btn btn-primary">登录</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>
<footer class="app-footer">
<div class="footer-content">
<a href="https://beian.miit.gov.cn/" target="_blank" class="beian-link">粤ICP备2025386132号-1</a>
</div>
</footer>
</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">
<a class="btn-back" @click="$router.push('/')" style="margin-bottom:16px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回首页
</a>
<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,
aluminumPrice: null,
aluminumHistory: [],
aluminumLoading: 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;
}
};
const loadAluminumPrice = async () => {
try {
const [current, history] = await Promise.all([
apiRequest('/api/aluminum-price/current').catch(() => null),
apiRequest('/api/aluminum-price/history?days=30').catch(() => null)
]);
state.aluminumPrice = current;
state.aluminumHistory = history || [];
} catch (e) {
console.error('铝价数据加载失败:', e);
} finally {
state.aluminumLoading = false;
nextTick(() => {
renderAluminumChart();
});
}
};
let chartInstance = null;
const renderAluminumChart = () => {
const canvas = document.getElementById('aluminumChart');
if (!canvas || !state.aluminumHistory.length) return;
if (chartInstance) chartInstance.destroy();
const ctx = canvas.getContext('2d');
const labels = state.aluminumHistory.map(d => d.date.slice(5));
const prices = state.aluminumHistory.map(d => d.close);
const gradient = ctx.createLinearGradient(0, 0, 0, 280);
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.35)');
gradient.addColorStop(1, 'rgba(59, 130, 246, 0.02)');
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: '铝价 (元/吨)',
data: prices,
borderColor: '#3b82f6',
backgroundColor: gradient,
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverBackgroundColor: '#3b82f6',
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(23, 23, 23, 0.9)',
titleFont: { size: 12 },
bodyFont: { size: 13 },
padding: 10,
cornerRadius: 8,
displayColors: false,
callbacks: {
label: ctx => '¥' + ctx.parsed.y.toLocaleString() + ' 元/吨'
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: '#a3a3a3', font: { size: 10 }, maxTicksLimit: 8 }
},
y: {
grid: { color: 'rgba(0,0,0,0.05)' },
ticks: { color: '#a3a3a3', font: { size: 10 }, callback: v => v.toLocaleString() }
}
}
}
});
};
onMounted(() => {
if (!appState.user) {
router.push('/login');
return;
}
loadStats();
loadAluminumPrice();
});
return { state, formatNumber, formatCurrency, appState, loadAluminumPrice };
},
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 class="aluminum-section">
<div class="card aluminum-price-card">
<div class="card-header">
<div class="card-title">
<span class="aluminum-icon">🪨</span>
铝金属实时行情
</div>
<div class="aluminum-source">数据来源: SHFE模拟</div>
</div>
<div class="card-body">
<div v-if="state.aluminumLoading" class="loading-state" style="padding: 20px;">
<div class="loading-spinner"></div>
<p>加载铝价数据...</p>
</div>
<div v-else-if="state.aluminumPrice" class="aluminum-content">
<div class="aluminum-price-row">
<div class="aluminum-current">
<div class="aluminum-price-value">¥{{ state.aluminumPrice.price?.toLocaleString() }}</div>
<div class="aluminum-price-unit">{{ state.aluminumPrice.unit }}</div>
</div>
<div class="aluminum-change" :class="state.aluminumPrice.change >= 0 ? 'price-up' : 'price-down'">
<span class="change-arrow">{{ state.aluminumPrice.change >= 0 ? '▲' : '▼' }}</span>
<span class="change-value">{{ Math.abs(state.aluminumPrice.change)?.toLocaleString() }}</span>
<span class="change-percent">({{ state.aluminumPrice.change_percent >= 0 ? '+' : '' }}{{ state.aluminumPrice.change_percent }}%)</span>
</div>
</div>
<div class="aluminum-detail-row">
<div class="aluminum-detail-item">
<span class="detail-label">开盘价</span>
<span class="detail-value">¥{{ state.aluminumPrice.open?.toLocaleString() }}</span>
</div>
<div class="aluminum-detail-item">
<span class="detail-label">最高价</span>
<span class="detail-value price-up-text">¥{{ state.aluminumPrice.high?.toLocaleString() }}</span>
</div>
<div class="aluminum-detail-item">
<span class="detail-label">最低价</span>
<span class="detail-value price-down-text">¥{{ state.aluminumPrice.low?.toLocaleString() }}</span>
</div>
<div class="aluminum-detail-item">
<span class="detail-label">昨收价</span>
<span class="detail-value">¥{{ state.aluminumPrice.prev_close?.toLocaleString() }}</span>
</div>
</div>
<div class="aluminum-chart-wrapper">
<div class="aluminum-chart-title">近30日价格走势</div>
<div class="aluminum-chart-container">
<canvas id="aluminumChart"></canvas>
</div>
</div>
</div>
<div v-else class="empty-state" style="padding: 20px;">
<p>铝价数据暂不可用</p>
</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 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;
if (!appState.token) {
state.error = '请先登录后再上传文件';
addNotification('请先登录', 'warning');
router.push('/login');
return;
}
if (appState.token === 'demo') {
state.error = '演示模式不支持文件上传,请使用完整账户登录';
addNotification('演示模式不支持上传', 'warning');
return;
}
state.uploading = true;
state.error = "";
state.progress = 0;
const formData = new FormData();
formData.append("file", state.selectedFile);
formData.append("material", state.selectedMaterial);
formData.append("draft_angle", String(state.moldParams.draftAngle));
formData.append("shrinkage_rate", String(state.moldParams.shrinkageRate));
formData.append("parting_precision", String(state.moldParams.partingPrecision));
formData.append("cavity_match", String(state.moldParams.cavityMatch));
try {
const res = await fetch("/api/upload", {
method: "POST",
headers: { 'Authorization': `Bearer ${appState.token}` },
body: formData
});
if (res.status === 401) {
clearAuth();
state.error = '登录已过期,请重新登录';
addNotification('登录已过期,请重新登录', 'warning');
router.push('/login');
return;
}
if (!res.ok) throw new Error(`上传失败: ${res.status}`);
const data = await res.json();
state.currentTask = { task_id: data.task_id, status: "processing", filename: data.file_info?.filename };
addNotification('文件上传成功,开始分析...', 'success');
startPolling(data.task_id);
} catch (e) {
state.error = handleApiError(e, '文件上传');
} finally {
state.uploading = false;
}
};
const startPolling = async (taskId) => {
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 moldParamFields = [
{
key: 'draftAngle',
label: '拔模角',
unit: '°',
min: 1,
max: 10,
step: 0.5,
defaultValue: 2.0,
hint: '常规注塑件建议从 1.5° 到 3° 起步'
},
{
key: 'shrinkageRate',
label: '收缩率',
unit: '%',
min: 0.5,
max: 3.0,
step: 0.1,
defaultValue: 0.5,
hint: '按材料牌号校核,默认值用于首轮方案评估'
},
{
key: 'partingPrecision',
label: '分型精度',
unit: 'mm',
min: 0.01,
max: 1.0,
step: 0.01,
defaultValue: 0.1,
hint: '用于控制分型面拟合与边界容差'
},
{
key: 'cavityMatch',
label: '型腔匹配度',
unit: '%',
min: 80,
max: 100,
step: 1,
defaultValue: 95,
hint: '数值越高越偏向紧配合与严格封合'
}
];
return {
state,
handleFileChange,
handleDrop,
uploadFile,
formatFileSize,
formatDateTime,
formatNumber,
moldParamFields,
toggleFileHistory,
viewResult
};
},
template: `
<div class="page-container">
<div class="page-header">
<h1>注塑模 STP 分析</h1>
<p>上传 STEP/STP 产品件,完成自动分模、工程建议与导出</p>
</div>
<div class="moldinsight-intro-grid">
<div class="intro-card">
<div class="intro-card-title">输入</div>
<div class="intro-card-text">STEP/STP 产品件,面向注塑模主流程</div>
</div>
<div class="intro-card">
<div class="intro-card-title">输出</div>
<div class="intro-card-text">分模方案、DFM 风险、注塑模系统摘要与 CAD 导出</div>
</div>
<div class="intro-card">
<div class="intro-card-title">目标</div>
<div class="intro-card-text">先确认推荐方案,再进入导出与 CAM 准备</div>
</div>
</div>
<div class="upload-layout">
<div class="upload-main-card">
<h2 class="section-title">1. 上传产品件</h2>
<div
:class="['upload-zone', { 'drag-over': state.dragOver }]"
@dragover.prevent="state.dragOver = true"
@dragleave.prevent="state.dragOver = false"
@drop="handleDrop"
@click="$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">点击选择或拖拽 STP/STEP 文件</span>
<span class="upload-hint">支持注塑模产品件分析,最大 100MB</span>
</div>
</div>
<div v-if="state.selectedFile" class="file-info">
<span class="file-name">{{ state.selectedFile.name }}</span>
<span class="file-size">{{ formatFileSize(state.selectedFile.size) }}</span>
<button class="btn-clear" @click="state.selectedFile = null" title="清除文件">×</button>
</div>
<div v-if="state.error" class="error-message">{{ state.error }}</div>
<div v-if="state.polling" class="progress-bar">
<div class="progress-fill" :style="{ width: state.progress + '%' }"></div>
</div>
</div>
<div class="upload-side-card">
<h2 class="section-title">2. 注塑模参数</h2>
<div class="material-panel compact-panel">
<div class="form-group">
<label class="form-label">产品材料</label>
<select v-model="state.selectedMaterial" class="form-select">
<option value="ABS">ABS (1.05 g/cm³)</option>
<option value="PP">PP (0.90 g/cm³)</option>
<option value="PE">PE (0.95 g/cm³)</option>
<option value="PC">PC (1.20 g/cm³)</option>
<option value="PA">PA (1.14 g/cm³)</option>
<option value="POM">POM (1.41 g/cm³)</option>
<option value="PMMA">PMMA (1.18 g/cm³)</option>
<option value="PBT">PBT (1.31 g/cm³)</option>
</select>
</div>
<details class="advanced-params">
<summary>高级工艺参数</summary>
<div class="advanced-params-body">
<div class="inline-note">默认值适用于多数注塑件;仅在已知工艺约束时再调整。</div>
<div class="advanced-param-grid">
<div class="param-input-card" v-for="field in moldParamFields" :key="field.key">
<label class="form-label">{{ field.label }}</label>
<div class="param-input-row">
<input
type="number"
class="form-input param-number-input"
v-model.number="state.moldParams[field.key]"
:min="field.min"
:max="field.max"
:step="field.step"
>
<span class="param-unit">{{ field.unit }}</span>
</div>
<div class="param-meta-row">
<span class="range-value">默认 {{ field.defaultValue }}{{ field.unit }}</span>
<span class="range-value">范围 {{ field.min }} - {{ field.max }}{{ field.unit }}</span>
</div>
<div class="param-hint">{{ field.hint }}</div>
</div>
</div>
</div>
</details>
</div>
<button
v-if="state.selectedFile"
class="btn-primary upload-submit-btn"
@click="uploadFile"
:disabled="state.uploading || state.polling"
>
{{ state.uploading ? '上传中...' : state.polling ? '分析中...' : '3. 开始注塑模分析' }}
</button>
<div v-else class="inline-note">先选择 STP 文件,再填写材料并开始分析。</div>
</div>
</div>
<details v-if="state.history?.files?.length" class="history-collapsible section">
<summary class="history-summary">分析历史({{ state.history.files.length }} 个文件)</summary>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>文件名</th>
<th>上传次数</th>
<th>文件大小</th>
<th>最新状态</th>
<th>最新分析时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<template v-for="file in state.history.files" :key="file.filename">
<tr>
<td>{{ file.filename }}</td>
<td>
<span class="badge badge-info">{{ file.upload_count }} 次</span>
</td>
<td>{{ formatFileSize(file.file_size) }}</td>
<td>
<span :class="['badge', file.latest_status === 'completed' ? 'badge-success' : file.latest_status === 'failed' ? 'badge-error' : 'badge-warning']">
{{ file.latest_status }}
</span>
</td>
<td>{{ formatDateTime(file.latest_upload_time) }}</td>
<td>
<div class="action-buttons">
<button v-if="file.latest_status === 'completed'" class="btn-sm btn-primary" @click="viewResult({task_id: file.latest_task_id})">
查看最新
</button>
<button class="btn-sm btn-icon" @click="toggleFileHistory(file.filename)" :title="state.expandedFiles[file.filename] ? '收起' : '展开历史记录'">
<span class="dropdown-icon" :class="{ 'expanded': state.expandedFiles[file.filename] }">▼</span>
</button>
</div>
</td>
</tr>
<tr v-if="state.expandedFiles[file.filename]" class="history-detail-row">
<td colspan="6">
<div class="history-dropdown">
<table class="data-table inner-table">
<thead>
<tr>
<th>上传时间</th>
<th>文件大小</th>
<th>状态</th>
<th>体积 (mm³)</th>
<th>表面积 (mm²)</th>
<th>重量 (g)</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="record in state.expandedFiles[file.filename]" :key="record.id">
<td>{{ formatDateTime(record.upload_time) }}</td>
<td>{{ formatFileSize(record.file_size) }}</td>
<td>
<span :class="['badge', record.status === 'completed' ? 'badge-success' : record.status === 'failed' ? 'badge-error' : 'badge-warning']">
{{ record.status }}
</span>
</td>
<td>{{ record.volume ? formatNumber(record.volume) : '-' }}</td>
<td>{{ record.surface_area ? formatNumber(record.surface_area) : '-' }}</td>
<td>{{ record.product_weight ? record.product_weight.toFixed(2) : '-' }}</td>
<td>
<button v-if="record.has_analysis" class="btn-sm btn-primary" @click="viewResult(record)">
查看详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</details>
</div>
`
};
const ResultView = {
setup() {
const route = useRoute();
const router = useRouter();
const state = reactive({
task: null,
loading: true,
error: '',
selectedSchemeId: null,
previewStatus: 'idle',
camLoading: false,
camError: '',
camPlan: null,
camForm: {
mold_steel: 'P20',
surface_quality: 'standard',
controller: 'fanuc',
include_gcode: false
}
});
const camSteelOptions = [
{ value: 'P20', label: 'P20' },
{ value: '718H', label: '718H' },
{ value: 'NAK80', label: 'NAK80' },
{ value: 'S136', label: 'S136' },
{ value: 'H13', label: 'H13' }
];
const camSurfaceOptions = [
{ value: 'standard', label: '标准' },
{ value: 'fine', label: '精细' },
{ value: 'mirror', label: '镜面' }
];
const camControllerOptions = [
{ value: 'fanuc', label: 'Fanuc' },
{ value: 'siemens', label: 'Siemens' }
];
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);
if (state.task?.best_scheme_id) {
state.selectedSchemeId = state.task.best_scheme_id;
} else if (state.task?.candidate_schemes?.length) {
state.selectedSchemeId = state.task.candidate_schemes[0].scheme_id;
}
const prefs = state.task?.cam_preferences || {};
state.camForm.mold_steel = prefs.mold_steel || 'P20';
state.camForm.surface_quality = prefs.surface_quality || 'standard';
state.camForm.controller = prefs.controller || 'fanuc';
state.camForm.include_gcode = Boolean(prefs.include_gcode || false);
state.previewStatus = 'loading';
} 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 candidateSchemes = computed(() => state.task?.candidate_schemes || []);
const hasSingleScheme = computed(() => candidateSchemes.value.length === 1);
const selectedScheme = computed(() => {
if (!candidateSchemes.value.length) return null;
return candidateSchemes.value.find(s => s.scheme_id === state.selectedSchemeId) || candidateSchemes.value[0];
});
const selectedCavityData = computed(() => selectedScheme.value?.cavity_data || state.task?.cavity_data || null);
const selectedKeyInfo = computed(() => selectedScheme.value?.key_info || state.task?.key_info || null);
const selectedHtmlFile = computed(() => selectedScheme.value?.html_file || state.task?.html_file || '');
const selectedDfmViolations = computed(() => selectedScheme.value?.dfm_violations || []);
const selectedInjectionSystem = computed(() => selectedCavityData.value?.injection_system || state.task?.plan_result?.injection_system || null);
const selectedSideActions = computed(() => {
return selectedScheme.value?.side_actions
|| selectedCavityData.value?.side_actions
|| state.task?.side_actions
|| null;
});
const parseEmbeddedSideActionAi = (report) => {
const source = String(report || '');
const match = source.match(/<!--SIDE_ACTION_AI_BEGIN-->\s*([\s\S]*?)\s*<!--SIDE_ACTION_AI_END-->/);
if (!match) return null;
try {
return JSON.parse(match[1]);
} catch (error) {
console.warn('AI 倒扣分析解析失败:', error);
return null;
}
};
const parseEmbeddedDesignReport = (report) => {
const source = String(report || '');
const match = source.match(/<!--DESIGN_REPORT_BEGIN-->\s*([\s\S]*?)\s*<!--DESIGN_REPORT_END-->/);
if (!match) return null;
try {
return JSON.parse(match[1]);
} catch (error) {
console.warn('LLM 设计报告解析失败:', error);
return null;
}
};
const stripEmbeddedSideActionAi = (report) => String(report || '')
.replace(/<!--SIDE_ACTION_AI_BEGIN-->\s*[\s\S]*?\s*<!--SIDE_ACTION_AI_END-->/, '')
.trim();
const normalizeSideActionAiAdvice = (raw, source = 'ai') => {
if (!raw || typeof raw !== 'object') return null;
const status = ['required', 'not_required', 'manual_review'].includes(raw.status) ? raw.status : 'manual_review';
const mechanism = ['slider', 'lifter', 'mixed', 'none', 'manual_review'].includes(raw.mechanism_recommendation)
? raw.mechanism_recommendation
: 'manual_review';
const confidence = Number(raw.confidence);
const statusLabelMap = {
required: '需要倒扣/抽芯',
not_required: '无需倒扣/抽芯',
manual_review: '需人工确认'
};
const mechanismLabelMap = {
slider: '优先滑块',
lifter: '优先斜顶',
mixed: '滑块 + 斜顶',
none: '无需侧向机构',
manual_review: '人工评审'
};
return {
source,
status,
statusLabel: statusLabelMap[status],
mechanismRecommendation: mechanism,
mechanismLabel: mechanismLabelMap[mechanism],
confidence: Number.isFinite(confidence) ? Math.max(0, Math.min(confidence, 1)) : null,
conclusion: raw.conclusion || statusLabelMap[status],
summary: raw.summary || '',
reasons: Array.isArray(raw.reasons) ? raw.reasons.filter(Boolean).slice(0, 5) : [],
standardAdvice: Array.isArray(raw.standard_advice) ? raw.standard_advice.filter(Boolean).slice(0, 5) : [],
manualReviewItems: Array.isArray(raw.manual_review_items) ? raw.manual_review_items.filter(Boolean).slice(0, 4) : []
};
};
const fallbackSideActionAiAdvice = computed(() => {
const sideActions = selectedSideActions.value;
if (!sideActions || typeof sideActions !== 'object') return null;
const summary = sideActions.summary || {};
const sliderCount = (sideActions.slider_mechanisms || []).length;
const lifterCount = (sideActions.lifter_mechanisms || []).length;
const totalCount = sliderCount + lifterCount;
const mechanismRecommendation = sliderCount && lifterCount
? 'mixed'
: sliderCount
? 'slider'
: lifterCount
? 'lifter'
: 'none';
const status = totalCount > 0
? 'required'
: summary.total_undercut_faces === 0
? 'not_required'
: 'manual_review';
return normalizeSideActionAiAdvice({
status,
confidence: 0.55,
conclusion: status === 'required'
? '规则分析判断当前产品需要倒扣/抽芯机构'
: status === 'not_required'
? '规则分析未发现必须采用倒扣/抽芯机构的特征'
: '当前规则结果不足以完成稳定判断,建议人工复核',
mechanism_recommendation: mechanismRecommendation === 'none' ? 'none' : mechanismRecommendation,
summary: status === 'required'
? `检测到 ${sliderCount} 个滑块需求、${lifterCount} 个斜顶需求,建议先按标准机构路线做结构评审。`
: status === 'not_required'
? '当前方案未发现明显倒扣特征,可优先按常规模具结构推进。'
: '现有数据可用于初筛,但不足以替代工程师对复杂倒扣的最终确认。',
reasons: [
totalCount > 0 ? `规则分析识别出 ${totalCount} 处侧向机构需求` : '规则分析未识别出明确侧向机构需求',
summary.complexity ? `当前复杂度判定为 ${summary.complexity}` : '当前复杂度信息不足',
'当前倒扣方案若存在大行程滑块,优先按气动抽芯条件评估'
].filter(Boolean),
standard_advice: (sideActions.recommendations || []).slice(0, 4),
manual_review_items: [
'结合 3D 预览复核倒扣是否可通过改产品取消',
'确认侧向机构与顶出、冷却、分型面是否存在干涉'
]
}, 'rules');
});
const dfmLevelSummary = computed(() => {
const base = { critical: 0, high: 0, medium: 0, low: 0 };
(selectedDfmViolations.value || []).forEach((item) => {
const level = String(item.level || 'medium').toLowerCase();
if (base[level] === undefined) {
base.medium += 1;
} else {
base[level] += 1;
}
});
return base;
});
const sideActionAiAdvice = computed(() => {
return normalizeSideActionAiAdvice(
parseEmbeddedSideActionAi(state.task?.llm_report || ''),
'ai'
) || fallbackSideActionAiAdvice.value;
});
const designReport = computed(() =>
parseEmbeddedDesignReport(state.task?.llm_report || '')
);
const hasVisibleDesignReport = computed(() =>
designReport.value && (designReport.value.sections?.length > 0 || designReport.value.overview)
);
const designReportSections = computed(() => {
const sections = designReport.value?.sections || [];
return sections.filter(s => {
if (s.type === 'issues') return s.items?.length > 0;
if (s.type === 'params_table') return s.rows?.length > 0;
if (s.type === 'recommendations') return s.items?.length > 0;
return true;
});
});
const stageTimingEntries = computed(() => {
const timings = state.task?.stage_timings || {};
return Object.entries(timings)
.filter(([, value]) => typeof value === 'number')
.sort((a, b) => b[1] - a[1]);
});
const sortedRecommendations = computed(() => {
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
return [...(state.task?.analysis_result?.design_recommendations || [])].sort((a, b) => {
return (priorityOrder[a.priority] ?? 99) - (priorityOrder[b.priority] ?? 99);
});
});
const primaryRecommendation = computed(() => sortedRecommendations.value[0] || null);
const selectedDfmCount = computed(() => selectedDfmViolations.value?.length || 0);
const selectedCavityCount = computed(() => {
const cavityValue = selectedCavityData.value?.mold_cavities?.cavity_count;
if (typeof cavityValue === 'number') return cavityValue;
const cavityObj = selectedCavityData.value?.mold_cavities || selectedKeyInfo.value?.mold_cavities || {};
return cavityObj.cavity_count || Object.keys(cavityObj).filter(key => key.startsWith('cavity_')).length || 1;
});
const selectedRiskLabel = computed(() => {
if (!selectedDfmCount.value) return '低风险';
if (selectedDfmCount.value >= 4) return '高风险';
if (selectedDfmCount.value >= 2) return '中风险';
return '低风险';
});
const resultAnchorLinks = [
{ id: 'candidate-schemes', label: '方案' },
{ id: 'preview-3d', label: '预览' },
{ id: 'ai-side-action', label: 'AI倒扣分析' },
{ id: 'export-cam', label: 'CAM/CNC' },
{ id: 'llm-report', label: '设计报告' }
];
const selectScheme = (schemeId) => {
state.selectedSchemeId = schemeId;
state.previewStatus = 'loading';
state.camPlan = null;
state.camError = '';
};
const onPreviewLoad = () => {
state.previewStatus = 'loaded';
};
const onPreviewError = () => {
state.previewStatus = 'error';
};
const formatSchemeDirection = (scheme) => {
if (!scheme) return 'N/A';
if (scheme.parting?.axis) return `${scheme.parting.axis} 轴`;
const dir = scheme.parting?.direction;
if (!Array.isArray(dir)) return 'N/A';
return `[${dir.map(v => Number(v).toFixed(2)).join(', ')}]`;
};
const analysisFeatures = computed(() => state.task?.analysis_result?.detected_features || []);
const getFeatureByTypes = (...types) => analysisFeatures.value.find(f => types.includes(f.feature_type));
const countFeaturesByTypes = (...types) => analysisFeatures.value.filter(f => types.includes(f.feature_type)).length;
const getWallThicknessSummary = () => {
const feature = getFeatureByTypes('thin_wall', 'thick_wall', 'wall_non_uniform');
if (!feature) return '待分析';
const params = feature.parameters || {};
if (params.min_thickness != null && params.max_thickness != null) {
return `${Number(params.min_thickness).toFixed(2)} - ${Number(params.max_thickness).toFixed(2)} mm`;
}
if (params.average_thickness != null) {
return `平均 ${Number(params.average_thickness).toFixed(2)} mm`;
}
return feature.recommendations?.[0] || '已完成分析';
};
const getUndercutCount = () => {
const fromScheme = selectedScheme.value?.undercut_regions?.length || selectedCavityData.value?.undercut_regions?.length || 0;
return fromScheme || countFeaturesByTypes('undercut');
};
const formatStageName = (name) => {
const stageNameMap = {
parse_stp: 'STP解析',
generate_mesh: '网格生成',
generate_cavity: '分模与型腔生成',
build_plan_result: '方案结果组装',
persist_artifacts: '结果持久化',
analyze_design: '几何分析',
verify_geometry: '几何验证',
generate_llm_report: 'LLM报告生成'
};
return stageNameMap[name] || name;
};
const formatTiming = (value) => `${Number(value || 0).toFixed(3)} s`;
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const applyInlineMarkdown = (text) => {
let html = escapeHtml(text);
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
return html;
};
const renderMarkdownToHtml = (markdown) => {
const source = String(markdown || '').replace(/\r\n/g, '\n').trim();
if (!source) return '';
const lines = source.split('\n');
const html = [];
let inList = false;
let inCode = false;
let codeBuffer = [];
const closeList = () => {
if (inList) {
html.push('</ul>');
inList = false;
}
};
const closeCode = () => {
if (inCode) {
html.push(`<pre><code>${escapeHtml(codeBuffer.join('\n'))}</code></pre>`);
inCode = false;
codeBuffer = [];
}
};
lines.forEach((rawLine) => {
const line = rawLine.trimEnd();
if (line.startsWith('```')) {
closeList();
if (inCode) {
closeCode();
} else {
inCode = true;
codeBuffer = [];
}
return;
}
if (inCode) {
codeBuffer.push(rawLine);
return;
}
const trimmed = line.trim();
if (!trimmed) {
closeList();
html.push('');
return;
}
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
closeList();
const level = headingMatch[1].length;
html.push(`<h${level}>${applyInlineMarkdown(headingMatch[2])}</h${level}>`);
return;
}
if (/^[-*]\s+/.test(trimmed)) {
if (!inList) {
html.push('<ul>');
inList = true;
}
html.push(`<li>${applyInlineMarkdown(trimmed.replace(/^[-*]\s+/, ''))}</li>`);
return;
}
if (/^\d+\.\s+/.test(trimmed)) {
closeList();
html.push(`<p>${applyInlineMarkdown(trimmed)}</p>`);
return;
}
if (/^>\s?/.test(trimmed)) {
closeList();
html.push(`<blockquote>${applyInlineMarkdown(trimmed.replace(/^>\s?/, ''))}</blockquote>`);
return;
}
closeList();
html.push(`<p>${applyInlineMarkdown(trimmed)}</p>`);
});
closeList();
closeCode();
return html.filter(Boolean).join('\n');
};
const exportCAD = async (format) => {
try {
const taskId = route.params.taskId;
const result = await apiRequest('/api/export-mold', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
task_id: taskId,
scheme_id: selectedScheme.value?.scheme_id || state.selectedSchemeId,
formats: [format],
components: ['cavity', 'core', 'parting_surface']
})
});
if (result.status === 'success' && result.data.files) {
for (const file of result.data.files) {
const relativePath = file.relative_path
|| (file.filepath ? file.filepath.replace(/\\/g, '/').split('/').slice(-2).join('/') : '');
const downloadUrl = file.download_path
|| `/api/export-download/${encodeURI(relativePath)}?task_id=${encodeURIComponent(taskId)}`;
await downloadWithAuth(downloadUrl, file.filename);
}
addNotification(`已导出 ${result.data.files.length} 个 ${format.toUpperCase()} 文件`, 'success');
} else if (result.data.errors && result.data.errors.length > 0) {
addNotification(`导出失败: ${result.data.errors[0]}`, 'error');
}
} catch (e) {
addNotification(`导出失败: ${e.message}`, 'error');
}
};
async function downloadWithAuth(url, filename) {
const headers = {};
if (appState.token) {
headers['Authorization'] = `Bearer ${appState.token}`;
}
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`下载失败 (${response.status}): 无法下载,需要授权`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}
const generateCamPlan = async () => {
try {
state.camLoading = true;
state.camError = '';
const taskId = route.params.taskId;
const payload = {
task_id: taskId,
scheme_id: selectedScheme.value?.scheme_id,
mold_steel: state.camForm.mold_steel,
surface_quality: state.camForm.surface_quality,
controller: state.camForm.controller,
include_gcode: state.camForm.include_gcode
};
const result = await apiRequest('/api/cam/plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
state.camPlan = result?.data || null;
if (result?.cam_preferences) {
state.camForm.mold_steel = result.cam_preferences.mold_steel || state.camForm.mold_steel;
state.camForm.surface_quality = result.cam_preferences.surface_quality || state.camForm.surface_quality;
state.camForm.controller = result.cam_preferences.controller || state.camForm.controller;
state.camForm.include_gcode = Boolean(result.cam_preferences.include_gcode);
}
addNotification('CAM 计划生成完成', 'success');
} catch (e) {
state.camPlan = null;
state.camError = e.message || 'CAM 计划生成失败';
addNotification(`CAM 计划生成失败: ${state.camError}`, 'error');
} finally {
state.camLoading = false;
}
};
return {
state,
candidateSchemes,
hasSingleScheme,
selectedScheme,
selectedCavityData,
selectedKeyInfo,
selectedHtmlFile,
selectedDfmViolations,
selectedInjectionSystem,
sideActionAiAdvice,
dfmLevelSummary,
designReport,
hasVisibleDesignReport,
designReportSections,
stageTimingEntries,
getWallThicknessSummary,
countFeaturesByTypes,
getUndercutCount,
formatStageName,
formatTiming,
primaryRecommendation,
sortedRecommendations,
selectedDfmCount,
selectedCavityCount,
selectedRiskLabel,
resultAnchorLinks,
formatFileSize,
formatDateTime,
formatNumber,
getPriorityText,
exportCAD,
generateCamPlan,
camSteelOptions,
camSurfaceOptions,
camControllerOptions,
selectScheme,
onPreviewLoad,
onPreviewError,
formatSchemeDirection
};
},
template: `
<div class="page-container">
<div class="page-header">
<button class="btn-back" @click="$router.back()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回
</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-anchor-nav">
<a
v-for="anchor in resultAnchorLinks"
:key="anchor.id"
class="anchor-pill"
:href="'#' + anchor.id"
>
{{ anchor.label }}
</a>
</div>
<div class="result-grid result-priority-grid">
<div class="result-card result-card-highlight" style="cursor: pointer;" @click="selectScheme(selectedScheme?.scheme_id)">
<div class="summary-header">
<h3>推荐方案</h3>
<span class="badge badge-info">{{ selectedScheme?.title || selectedScheme?.scheme_id || '方案待定' }}</span>
</div>
<div class="info-list">
<div class="info-item">
<span class="info-label">方案说明</span>
<span class="info-value" style="font-weight: 600; font-size: 1.05rem; color: var(--text-primary);">{{ selectedScheme?.summary || selectedScheme?.reason || '基于当前几何与制造约束自动推荐' }}</span>
</div>
<div class="info-item">
<span class="info-label">分型方向</span>
<span class="info-value">{{ formatSchemeDirection(selectedScheme) }}</span>
</div>
<div class="info-item">
<span class="info-label">模具结构</span>
<span class="info-value">{{ selectedScheme?.mold_structure_type === 'two_half_cavity' ? '两板半腔(无独立模芯)' : '型腔 + 模芯' }}</span>
</div>
<div class="info-item">
<span class="info-label">分型面位置</span>
<span class="info-value">{{ selectedScheme?.offset_label || '中面' }}</span>
</div>
<div class="info-item">
<span class="info-label">型腔数</span>
<span class="info-value">{{ selectedCavityCount }} 腔</span>
</div>
<div class="info-item">
<span class="info-label">预计成型周期</span>
<span class="info-value">{{ selectedInjectionSystem?.overall_assessment?.estimated_cycle_time || 'N/A' }} s</span>
</div>
</div>
</div>
<div class="result-card result-card-highlight" v-if="candidateSchemes.length > 1" style="cursor: pointer;" @click="selectScheme(candidateSchemes[1]?.scheme_id)">
<div class="summary-header">
<h3>备选方案</h3>
<span class="badge badge-warning">1 个备选</span>
</div>
<div class="info-list">
<template v-for="scheme in candidateSchemes.slice(1, 2)" :key="'alt-' + scheme.scheme_id">
<div class="info-item">
<span class="info-label">方案说明</span>
<span class="info-value">{{ scheme.summary || scheme.reason || '暂无说明' }}</span>
</div>
<div class="info-item">
<span class="info-label">分型方向</span>
<span class="info-value">{{ formatSchemeDirection(scheme) }}</span>
</div>
<div class="info-item">
<span class="info-label">模具结构</span>
<span class="info-value">{{ scheme.mold_structure_type === 'two_half_cavity' ? '两板半腔(无独立模芯)' : '型腔 + 模芯' }}</span>
</div>
<div class="info-item">
<span class="info-label">分型面位置</span>
<span class="info-value">{{ scheme.offset_label || '中面' }}</span>
</div>
<div class="info-item">
<span class="info-label">方案总分</span>
<span class="info-value">{{ formatNumber(scheme.score) }}</span>
</div>
</template>
</div>
</div>
<div class="result-card result-card-highlight" v-else>
<div class="summary-header">
<h3>方案概览</h3>
<span class="badge badge-success">单一方案</span>
</div>
<div class="info-list">
<div class="info-item">
<span class="info-label">详细方案说明</span>
<span class="info-value" style="font-weight: 500;">{{ selectedScheme?.summary || selectedScheme?.reason || '当前仅生成 1 套可用分模方案,已在推荐方案区域展示核心参数。' }}</span>
</div>
</div>
</div>
</div>
<div v-if="state.task.status === 'completed'" class="export-buttons-bar" style="display: flex; gap: var(--space-2); flex-wrap: wrap; justify-content: center; padding: var(--space-3) 0;">
<button class="btn-sm btn-primary" @click="exportCAD('step')" title="导出装配体STEP格式(UG/FreeCAD/SolidWorks通用)">
📦 导出 STEP
</button>
<button class="btn-sm btn-secondary" @click="exportCAD('iges')" title="导出IGES格式(兼容旧系统)">
导出 IGES
</button>
<button class="btn-sm btn-secondary" @click="exportCAD('stl')" title="导出STL网格格式(3D打印预览)">
导出 STL
</button>
<button class="btn-sm btn-secondary" @click="exportCAD('brep')" title="导出BRep格式(FreeCAD原生)">
导出 BRep
</button>
</div>
<div id="preview-3d" v-if="selectedHtmlFile" class="viewer-section viewer-section-hero">
<div class="summary-header">
<h3>3D 预览</h3>
<span class="badge badge-info">重点区域</span>
</div>
<div v-if="state.previewStatus === 'error'" class="inline-alert inline-alert-warning" style="margin-bottom: var(--space-3);">
<div class="inline-alert-title">预览加载失败</div>
<div class="inline-alert-message">HTML 已生成但加载异常,请检查该链接是否可访问:{{ selectedHtmlFile }}</div>
</div>
<iframe
:key="selectedHtmlFile"
:src="selectedHtmlFile"
class="viewer-frame viewer-frame-hero"
@load="onPreviewLoad"
@error="onPreviewError"
></iframe>
</div>
<div id="preview-3d" v-else class="viewer-section viewer-section-hero">
<div class="summary-header">
<h3>3D 预览</h3>
<span class="badge badge-warning">未生成</span>
</div>
<div class="inline-alert inline-alert-warning">
<div class="inline-alert-title">预览未生成</div>
<div class="inline-alert-message">当前任务没有返回 HTML 预览链接,属于未生成状态。</div>
</div>
</div>
<div id="candidate-schemes" v-if="candidateSchemes.length > 1" class="viewer-section diagnostic-panel" style="margin-top: var(--space-3);">
<summary>📊 方案对比</summary>
<div class="result-card full-width diagnostic-panel-body">
<table class="data-table">
<thead>
<tr>
<th>方案</th>
<th>方向</th>
<th>位置</th>
<th>总分</th>
<th>可制造性</th>
<th>分型质量</th>
<th>倒扣数</th>
<th>锁模力</th>
</tr>
</thead>
<tbody>
<tr v-for="(scheme, idx) in candidateSchemes.slice(0, 2)" :key="'cmp-' + scheme.scheme_id" :style="state.selectedSchemeId === scheme.scheme_id ? 'background: rgba(var(--primary-rgb), 0.06); font-weight: 600;cursor:pointer' : 'cursor:pointer'" @click="selectScheme(scheme.scheme_id)">
<td>
<span v-if="idx === 0" style="color: var(--primary-color); font-weight: 600;">★ 推荐方案</span>
<span v-else style="color: var(--warning-color); font-weight: 600;">备选方案</span>
</td>
<td>{{ formatSchemeDirection(scheme) }}</td>
<td>{{ scheme.offset_label || '中面' }}</td>
<td>{{ formatNumber(scheme.score) }}</td>
<td>{{ formatNumber(scheme.score_breakdown?.manufacturability || 0) }}</td>
<td>{{ formatNumber(scheme.score_breakdown?.parting_quality || 0) }}</td>
<td>{{ scheme.key_info?.quality_considerations?.undercut_count || 0 }}</td>
<td>{{ scheme.cavity_data?.manufacturing_info?.estimated_clamping_force || 'N/A' }}</td>
</tr>
</tbody>
</table>
</div>
</details>
<div id="ai-side-action" class="viewer-section">
<div class="summary-header">
<h3>AI 倒扣与抽芯分析</h3>
<span
v-if="sideActionAiAdvice"
:class="[
'badge',
sideActionAiAdvice.status === 'required'
? 'badge-error'
: sideActionAiAdvice.status === 'not_required'
? 'badge-success'
: 'badge-warning'
]"
>
{{ sideActionAiAdvice.statusLabel }}
</span>
<span v-else class="badge badge-warning">未生成</span>
</div>
<div v-if="sideActionAiAdvice" class="result-grid">
<div class="result-card result-card-highlight">
<h4>AI 结论</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">判断结果</span>
<span class="info-value">{{ sideActionAiAdvice.conclusion }}</span>
</div>
<div class="info-item">
<span class="info-label">推荐方案</span>
<span class="info-value">{{ sideActionAiAdvice.mechanismLabel }}</span>
</div>
<div class="info-item" v-if="sideActionAiAdvice.confidence != null">
<span class="info-label">置信度</span>
<span class="info-value">{{ Math.round(sideActionAiAdvice.confidence * 100) }}%</span>
</div>
<div class="info-item">
<span class="info-label">分析来源</span>
<span class="info-value">{{ sideActionAiAdvice.source === 'ai' ? 'AI 模型' : '规则回退' }}</span>
</div>
</div>
<div class="inline-note" style="margin-top: var(--space-2); font-weight: 600; font-size: 1rem; color: var(--text-primary);">{{ sideActionAiAdvice.summary || '当前任务未返回更多摘要。' }}</div>
</div>
<div class="result-card">
<div v-if="sideActionAiAdvice.reasons.length">
<h4>判断依据</h4>
<div class="recommendations-list">
<div class="recommendation-item medium" v-for="(reason, idx) in sideActionAiAdvice.reasons" :key="'ai-reason-' + idx">
<div class="recommendation-text">{{ reason }}</div>
</div>
</div>
</div>
<div v-if="sideActionAiAdvice.standardAdvice.length" style="margin-top: var(--space-3);">
<h4>标准化建议</h4>
<div class="recommendations-list">
<div class="recommendation-item low" v-for="(advice, idx) in sideActionAiAdvice.standardAdvice" :key="'ai-advice-' + idx">
<div class="recommendation-text">{{ advice }}</div>
</div>
</div>
</div>
<div v-if="sideActionAiAdvice.manualReviewItems.length" style="margin-top: var(--space-3);">
<h4>人工复核项</h4>
<div class="recommendations-list">
<div class="recommendation-item medium" v-for="(item, idx) in sideActionAiAdvice.manualReviewItems" :key="'ai-review-' + idx">
<div class="recommendation-text">{{ item }}</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="inline-alert inline-alert-warning">
<div class="inline-alert-title">AI 结论未生成</div>
<div class="inline-alert-message">当前任务未生成倒扣与抽芯 AI 分析,请检查模型配置或重新运行分析。</div>
</div>
</div>
<div id="export-cam" class="viewer-section">
<div class="summary-header">
<h3>CAM 与 CNC 加工</h3>
<span class="badge badge-info">工艺规划</span>
</div>
<div class="result-card" style="margin-bottom: var(--space-4);">
<h4>CAM 参数</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">模具钢</span>
<select v-model="state.camForm.mold_steel" class="form-select">
<option v-for="opt in camSteelOptions" :key="'steel-' + opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div class="info-item">
<span class="info-label">表面质量</span>
<select v-model="state.camForm.surface_quality" class="form-select">
<option v-for="opt in camSurfaceOptions" :key="'surf-' + opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div class="info-item">
<span class="info-label">控制器</span>
<select v-model="state.camForm.controller" class="form-select">
<option v-for="opt in camControllerOptions" :key="'ctrl-' + opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div class="info-item">
<span class="info-label">包含G代码</span>
<label class="checkbox-group">
<input type="checkbox" class="checkbox-input" v-model="state.camForm.include_gcode" />
<span class="checkbox-label">返回G代码文本</span>
</label>
</div>
</div>
<button class="btn-sm btn-primary" :disabled="state.camLoading" @click="generateCamPlan()" style="margin-top: var(--space-2);" title="基于当前选中方案生成CAM工艺计划">
{{ state.camLoading ? '⏳ 生成中...' : '🔧 生成 CAM 计划' }}
</button>
</div>
<div v-if="state.camError" class="inline-alert inline-alert-warning">
<div class="inline-alert-title">CAM 计划生成失败</div>
<div class="inline-alert-message">{{ state.camError }}</div>
</div>
<div v-else-if="!state.camPlan" class="inline-alert inline-alert-warning">
<div class="inline-alert-title">尚未生成 CAM 计划</div>
<div class="inline-alert-message">点击上方“生成 CAM 计划”后,将返回工序计划、刀具建议与制造风险提示。</div>
</div>
<template v-else>
<div class="result-grid">
<div class="result-card">
<h4>计划摘要</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">方案ID</span>
<span class="info-value">{{ state.camPlan.scheme_id || 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">总工序数</span>
<span class="info-value">{{ state.camPlan.summary?.total_operations || 0 }}</span>
</div>
<div class="info-item">
<span class="info-label">预计总时长</span>
<span class="info-value">{{ formatNumber(state.camPlan.summary?.total_estimated_time_min || 0) }} min</span>
</div>
<div class="info-item">
<span class="info-label">方案可信度</span>
<span class="info-value">{{ formatNumber(state.camPlan.confidence?.score || 0) }}</span>
</div>
</div>
</div>
<div class="result-card">
<h4>刀具建议</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">粗加工刀具</span>
<span class="info-value">{{ state.camPlan.tooling_suggestion?.roughing_tool?.tool_id || 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">精加工刀具</span>
<span class="info-value">{{ state.camPlan.tooling_suggestion?.finishing_tool?.tool_id || 'N/A' }}</span>
</div>
</div>
</div>
</div>
<div class="result-card full-width">
<h4>工序计划</h4>
<table class="data-table">
<thead>
<tr>
<th>序号</th>
<th>工序</th>
<th>刀具</th>
<th>主轴转速</th>
<th>进给率</th>
<th>预计时长(min)</th>
</tr>
</thead>
<tbody>
<tr v-for="op in (state.camPlan.process_plan || [])" :key="'cam-op-' + op.seq">
<td>{{ op.seq }}</td>
<td>{{ op.operation }}</td>
<td>{{ op.tool_id || 'N/A' }}</td>
<td>{{ formatNumber(op.spindle_speed_rpm || 0) }}</td>
<td>{{ formatNumber(op.feed_rate_mm_min || 0) }}</td>
<td>{{ formatNumber(op.estimated_time_min || 0) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="result-card full-width">
<h4>制造风险提示</h4>
<div class="recommendations-list">
<div class="recommendation-item medium" v-for="(warn, idx) in (state.camPlan.manufacturing_warnings || [])" :key="'cam-warn-' + idx">
<div class="recommendation-text">{{ warn }}</div>
</div>
</div>
</div>
</template>
</div>
<div id="llm-report" class="viewer-section" v-if="hasVisibleDesignReport">
<div class="summary-header">
<h3>LLM 设计报告</h3>
<span class="badge" :class="(designReport.overall_score || 0) >= 7 ? 'badge-success' : (designReport.overall_score || 0) >= 5 ? 'badge-warning' : 'badge-error'">
评分 {{ designReport.overall_score || 'N/A' }} / 10
</span>
</div>
<div v-if="designReport.overview" class="inline-note" style="margin-bottom: var(--space-4); font-size: 0.95rem; color: var(--text-primary);">
{{ designReport.overview }}
</div>
<div class="result-grid">
<template v-for="(sec, si) in designReportSections" :key="'dr-sec-' + si">
<div class="result-card" :class="sec.type === 'issues' ? 'result-card-highlight' : ''">
<h4 :style="sec.type === 'issues' ? 'color: var(--danger-color);' : ''">{{ sec.heading }}</h4>
<template v-if="sec.type === 'issues'">
<div class="recommendations-list">
<div
v-for="(item, ii) in sec.items"
:key="'dr-issue-' + ii"
:class="['recommendation-item', item.level === 'high' ? 'high' : item.level === 'medium' ? 'medium' : 'low']"
>
<span class="priority-badge" :class="item.level || 'medium'" style="margin-right: var(--space-1);">{{ item.level === 'high' ? '🔴' : item.level === 'medium' ? '🟡' : '🟢' }}</span>
<div class="recommendation-text">{{ item.content }}</div>
</div>
</div>
</template>
<template v-else-if="sec.type === 'params_table'">
<table class="data-table">
<thead>
<tr>
<th v-for="(h, hi) in sec.headers" :key="'dr-h-' + hi">{{ h }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, ri) in sec.rows" :key="'dr-r-' + ri">
<td v-for="(cell, ci) in row" :key="'dr-c-' + ci">{{ cell }}</td>
</tr>
</tbody>
</table>
</template>
<template v-else-if="sec.type === 'recommendations'">
<div class="recommendations-list">
<div class="recommendation-item low" v-for="(item, ii) in sec.items" :key="'dr-rec-' + ii">
<div class="recommendation-text">💡 {{ item }}</div>
</div>
</div>
</template>
<div v-else class="inline-note">{{ JSON.stringify(sec) }}</div>
</div>
</template>
</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: [],
materialConsumptionItems: [],
showMaterialConsumptionModal: false,
consumedMaterials: [],
restockItems: [],
showRestockModal: false,
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();
// 提取日期部分,忽略时间部分
if (text.includes('T')) {
return text.split('T')[0];
}
if (text.includes(' ')) {
return text.split(' ')[0];
}
// 如果是只有日期部分的格式 (yyyy-MM-dd)
if (text.length === 10) {
return text; // 直接返回日期格式,后端 Pydantic 会自动处理
}
// 如果是日期对象
if (value instanceof Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
return text;
};
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 getPurchaseOrderStatusLabel = (status) => {
const statusMap = {
draft: '已下单',
pending: '已下单',
received: '已收货',
paid: '已付款'
};
return statusMap[status] || status;
};
const isPurchaseOrderLocked = (status) => {
return ['received', 'paid'].includes(status);
};
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?.items || [];
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?.items || [];
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 {
const data = await apiRequest('/api/inventory');
state.inventory = data?.items || [];
} catch (e) {
handleApiError(e, '加载库存');
} finally {
state.loading = false;
}
};
const loadMovements = async () => {
state.loading = true;
try {
const data = await apiRequest('/api/stock-movements');
state.movements = data?.items || [];
} 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}`);
// 加载已消耗的物料
const movements = await apiRequest(`/api/stock-movements?reference_type=sales_order&reference_id=${item.id}&movement_type=consumption`);
state.consumedMaterials = (movements || []).map(movement => ({
material_name: movement.product_name || movement.product_sku || '未知物料',
quantity: Math.abs(movement.quantity),
unit_price: movement.unit_price || 0,
amount: movement.total_amount || 0
}));
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 if (type === 'product') {
state.form = { ...item };
if (item.item_type === 'finished') {
await loadMaterials();
const bom = await apiRequest(`/api/products/${item.id}/materials`);
state.productBomItems = (bom.items || []).map(bomItem => ({
material_id: bomItem.material_id,
quantity: bomItem.quantity
}));
}
} 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 openMaterialConsumptionModal = async () => {
// 确保是在销售订单编辑或新增页面
if (state.modalType !== 'salesOrder') {
addNotification('请先打开销售订单编辑页面', 'warning');
return;
}
// 加载物料和仓库数据
await loadMaterials();
await loadWarehouses();
// 初始化物料消耗列表
state.materialConsumptionItems = [];
// 打开物料消耗模态框
state.showMaterialConsumptionModal = true;
};
const addMaterialConsumptionItem = () => {
state.materialConsumptionItems.push({
material_id: state.materials[0]?.id || null,
quantity: 1,
remark: ''
});
};
const removeMaterialConsumptionItem = (index) => {
state.materialConsumptionItems.splice(index, 1);
};
const saveMaterialConsumption = async () => {
// 确保是在销售订单页面
if (state.modalType !== 'salesOrder') {
addNotification('请先打开销售订单编辑页面', 'warning');
return;
}
// 如果是新增订单,先保存订单再添加物料消耗
if (!state.editingItem) {
// 关闭物料消耗模态框
state.showMaterialConsumptionModal = false;
await saveSalesOrder();
return;
}
// 验证物料消耗项
for (const [i, item] of state.materialConsumptionItems.entries()) {
const idx = i + 1;
if (!item.material_id) {
addNotification(`第 ${idx} 行:请选择物料`, 'warning');
return;
}
if (!Number.isFinite(item.quantity) || item.quantity <= 0) {
addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning');
return;
}
}
try {
// 调用 API 保存物料消耗
const result = await apiRequest(`/api/sales-orders/${state.editingItem.id}/consume-materials`, {
method: 'POST',
body: JSON.stringify({
items: state.materialConsumptionItems.map(item => ({
material_id: item.material_id,
quantity: item.quantity,
remark: item.remark
}))
})
});
addNotification('物料消耗记录保存成功', 'success');
// 关闭模态框
state.showMaterialConsumptionModal = false;
// 刷新订单详情
const detail = await apiRequest(`/api/sales-orders/${state.editingItem.id}`);
state.form = {
...state.form,
actual_material_cost: detail.actual_material_cost
};
// 刷新已消耗的物料
const movements = await apiRequest(`/api/stock-movements?reference_type=sales_order&reference_id=${state.editingItem.id}&movement_type=consumption`);
state.consumedMaterials = (movements || []).map(movement => ({
material_name: movement.product_name || movement.product_sku || '未知物料',
quantity: Math.abs(movement.quantity),
unit_price: movement.unit_price || 0,
amount: movement.total_amount || 0
}));
} catch (e) {
handleApiError(e, '保存物料消耗');
}
};
const openRestockModal = async () => {
// 加载物料和供应商数据
await loadMaterials();
await loadSuppliers();
// 初始化补货列表
state.restockItems = [];
// 打开补货模态框
state.showRestockModal = true;
};
const addRestockItem = () => {
state.restockItems.push({
material_id: state.materials[0]?.id || null,
quantity: 1,
unit_price: 0,
remark: ''
});
};
const removeRestockItem = (index) => {
state.restockItems.splice(index, 1);
};
const saveRestock = async () => {
// 验证补货项
if (!state.restockItems || state.restockItems.length === 0) {
addNotification('请至少添加一个补货物料', 'warning');
return;
}
for (const [i, item] of state.restockItems.entries()) {
const idx = i + 1;
if (!item.material_id) {
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;
}
}
try {
// 创建采购订单
const payload = {
supplier_id: state.suppliers[0]?.id || null,
expected_date: new Date().toISOString().split('T')[0],
remark: '物料补货',
items: state.restockItems.map(item => ({
product_id: item.material_id,
quantity: item.quantity,
remark: item.remark
}))
};
const result = await apiRequest('/api/purchase-orders', {
method: 'POST',
body: JSON.stringify(payload)
});
addNotification('采购订单创建成功', 'success');
// 关闭补货模态框
state.showRestockModal = false;
// 清空补货列表
state.restockItems = [];
// 刷新采购订单列表
loadPurchaseOrders();
} catch (e) {
handleApiError(e, '保存补货订单');
}
};
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)
});
if (state.form.item_type === 'finished' && state.productBomItems.length > 0) {
await apiRequest(`/api/products/${state.editingItem.id}/materials`, {
method: 'PUT',
body: JSON.stringify({ items: state.productBomItems })
});
}
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 addBomItem = () => {
state.productBomItems.push({
material_id: state.materials[0]?.id || null,
quantity: 1
});
};
const removeBomItem = (idx) => {
state.productBomItems.splice(idx, 1);
};
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 || ''
}))
};
let orderId;
if (state.editingItem) {
const result = await apiRequest(`/api/sales-orders/${state.editingItem.id}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
orderId = state.editingItem.id;
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)
});
orderId = result.id;
addNotification(result.production_status === 'bom_missing' ? '订单已创建,但成品未配置BOM,未扣减物料' : '销售订单创建成功并已自动扣减物料', result.production_status === 'bom_missing' ? 'warning' : 'success');
}
// 如果有物料消耗记录,保存物料消耗
if (state.materialConsumptionItems && state.materialConsumptionItems.length > 0) {
try {
// 调用 API 保存物料消耗
const result = await apiRequest(`/api/sales-orders/${orderId}/consume-materials`, {
method: 'POST',
body: JSON.stringify({
items: state.materialConsumptionItems.map(item => ({
material_id: item.material_id,
quantity: item.quantity,
remark: item.remark
}))
})
});
addNotification('物料消耗记录保存成功', 'success');
// 清空物料消耗记录
state.materialConsumptionItems = [];
} catch (e) {
handleApiError(e, '保存物料消耗');
}
}
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 updatePurchaseOrderStatus = async (order, targetStatus) => {
try {
await apiRequest(`/api/purchase-orders/${order.id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status: targetStatus })
});
addNotification('采购订单状态已更新', 'success');
loadPurchaseOrders();
} 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,
addBomItem,
removeBomItem,
saveSupplier,
deleteSupplier,
saveCustomer,
deleteCustomer,
saveInventoryItem,
deleteInventoryItem,
addSalesOrderItem,
removeSalesOrderItem,
setSalesOrderLineMode,
saveSalesOrder,
updateSalesOrderStatus,
updatePurchaseOrderStatus,
deleteSalesOrder,
addPurchaseOrderItem,
removePurchaseOrderItem,
savePurchaseOrder,
deletePurchaseOrder,
receivePurchaseOrder,
loadPurchaseOrders,
loadProductionOrders,
refreshFinanceByPeriod,
getMovementTypeLabel,
getMovementBadgeClass,
openMaterialConsumptionModal,
addMaterialConsumptionItem,
removeMaterialConsumptionItem,
saveMaterialConsumption,
openRestockModal,
addRestockItem,
removeRestockItem,
saveRestock,
getPurchaseOrderStatusLabel,
isPurchaseOrderLocked
};
},
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="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>
<button class="btn btn-secondary" @click="openRestockModal">+ 物料补货</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>
<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>{{ getPurchaseOrderStatusLabel(order.status) }}</td>
<td>{{ order.created_at ? formatDateTime(order.created_at) : '-' }}</td>
<td>{{ order.expected_date ? formatDate(order.expected_date) : '-' }}</td>
<td>{{ order.received_date ? formatDateTime(order.received_date) : '-' }}</td>
<td>{{ order.paid_date ? formatDateTime(order.paid_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)" :disabled="isPurchaseOrderLocked(order.status)">编辑</button>
<button class="btn btn-sm btn-danger" @click="deletePurchaseOrder(order.id)" :disabled="isPurchaseOrderLocked(order.status)">删除</button>
<button v-if="order.status === 'pending'" class="btn btn-sm btn-primary" @click="openModal('purchaseReceive', order)">到货入库</button>
<button v-else-if="order.status === 'received'" class="btn btn-sm btn-success" @click="updatePurchaseOrderStatus(order, 'paid')">标记为已付款</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.form.item_type === 'finished' ? '成品' : '物料') : 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 v-if="state.form.item_type === 'finished' && state.editingItem" class="bom-section">
<div class="form-group">
<label class="form-label">BOM物料配置</label>
<button type="button" class="btn btn-secondary" @click="addBomItem" style="margin-bottom: 8px;">+ 添加物料</button>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>物料</th>
<th>数量</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in state.productBomItems" :key="'edit-bom-item-' + idx">
<td>
<select v-model.number="item.material_id" class="form-input" required>
<option v-for="material in state.materials" :key="'edit-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><button type="button" class="btn btn-sm btn-danger" @click="removeBomItem(idx)">删除</button></td>
</tr>
</tbody>
</table>
</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 === '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="form-group" style="margin-bottom: var(--space-4);">
<button type="button" class="btn btn-secondary" @click="openMaterialConsumptionModal">+ 消耗物料</button>
</div>
<div class="form-group" style="margin-bottom: var(--space-4);">
<label class="form-label">已消耗物料</label>
<div v-if="state.editingItem && state.editingItem.actual_material_cost > 0" class="material-consumption-list">
<div class="material-consumption-header">
<div class="material-consumption-item">物料名称</div>
<div class="material-consumption-item">数量</div>
<div class="material-consumption-item">单价</div>
<div class="material-consumption-item">金额</div>
</div>
<div v-for="(item, index) in state.consumedMaterials" :key="'consumed-material-' + index" class="material-consumption-row">
<div class="material-consumption-item">{{ item.material_name }}</div>
<div class="material-consumption-item">{{ item.quantity }}</div>
<div class="material-consumption-item">{{ formatCurrency(item.unit_price) }}</div>
<div class="material-consumption-item">{{ formatCurrency(item.amount) }}</div>
</div>
<div class="material-consumption-total">
<div class="material-consumption-item">合计</div>
<div class="material-consumption-item"></div>
<div class="material-consumption-item"></div>
<div class="material-consumption-item">{{ formatCurrency(state.editingItem.actual_material_cost) }}</div>
</div>
</div>
<div v-else class="empty-state">
<div class="empty-icon">📦</div>
<div class="empty-title">暂无物料消耗记录</div>
<div class="empty-desc">点击"消耗物料"按钮添加物料消耗明细</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 === '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 v-if="state.showMaterialConsumptionModal" class="modal-overlay" @click.self="state.showMaterialConsumptionModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>添加物料消耗</h3>
<button class="modal-close" @click="state.showMaterialConsumptionModal = false">&times;</button>
</div>
<div class="modal-body">
<div class="form-group" style="margin-bottom: var(--space-4);">
<button type="button" class="btn btn-secondary" @click="addMaterialConsumptionItem">+ 添加物料</button>
</div>
<div v-if="state.materialConsumptionItems.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.materialConsumptionItems" :key="'material-consumption-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.material_id" class="form-input" required>
<option value="">请选择物料</option>
<option v-for="material in state.materials" :key="'consumption-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.material_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.material_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="removeMaterialConsumptionItem(index)">删除</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="state.showMaterialConsumptionModal = false">取消</button>
<button type="button" class="btn btn-primary" @click="saveMaterialConsumption">保存物料消耗</button>
</div>
</div>
</div>
</div>
<!-- 物料补货模态框 -->
<div v-if="state.showRestockModal" class="modal-overlay" @click.self="state.showRestockModal = false">
<div class="modal-content" style="max-width: 900px;">
<div class="modal-header">
<h3>物料补货</h3>
<button class="modal-close" @click="state.showRestockModal = false">&times;</button>
</div>
<div class="modal-body">
<div class="form-section">
<div class="section-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h4>补货物料明细</h4>
<button type="button" class="btn btn-secondary" @click="addRestockItem">+ 添加物料</button>
</div>
<div v-if="state.restockItems.length === 0" class="empty-state">
<div class="empty-icon">📦</div>
<div class="empty-desc">请点击"添加物料"按钮添加补货物料明细</div>
</div>
<div v-else class="purchase-order-items">
<div v-for="(line, index) in state.restockItems" :key="'restock-line-' + index" class="purchase-order-item">
<div class="purchase-order-item-row">
<div class="purchase-order-item-column" style="flex: 2;">
<div class="form-group">
<label class="form-label">物料 <span class="required">*</span></label>
<select v-model.number="line.material_id" class="form-input" required>
<option v-for="material in state.materials" :key="'restock-material-' + material.id" :value="material.id">
{{ material.sku }} - {{ material.name }}
</option>
</select>
</div>
</div>
<div class="purchase-order-item-column">
<div class="form-group">
<label class="form-label">数量 <span class="required">*</span></label>
<input v-model.number="line.quantity" type="number" class="form-input" min="1" step="1" required />
</div>
</div>
<div class="purchase-order-item-column">
<div class="form-group">
<label class="form-label">单价</label>
<input v-model.number="line.unit_price" type="number" class="form-input" min="0" step="0.01" />
</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);">
¥{{ ((line.unit_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="removeRestockItem(index)">删除</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="state.showRestockModal = false">取消</button>
<button type="button" class="btn btn-primary" @click="saveRestock">创建采购订单</button>
</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");