5428 lines
214 KiB
JavaScript
5428 lines
214 KiB
JavaScript
/**
|
||
* Gemold - 模具制造管理系统
|
||
* 版本: 4.0.0
|
||
*/
|
||
|
||
const { createApp, ref, computed, onMounted, reactive, watch, nextTick } = Vue;
|
||
const { createRouter, createWebHistory, useRoute, useRouter } = VueRouter;
|
||
|
||
const appState = reactive({
|
||
user: null,
|
||
token: null,
|
||
loading: false,
|
||
notifications: [],
|
||
initialized: false
|
||
});
|
||
|
||
function formatFileSize(bytes) {
|
||
if (!bytes || bytes === 0) return "0 B";
|
||
const k = 1024;
|
||
const sizes = ["B", "KB", "MB", "GB"];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||
}
|
||
|
||
function formatNumber(num) {
|
||
if (num === null || num === undefined) return "N/A";
|
||
if (num >= 1_000_000) return (num / 1_000_000).toFixed(2) + "M";
|
||
if (num >= 1_000) return (num / 1_000).toFixed(2) + "K";
|
||
return num.toFixed ? num.toFixed(2) : String(num);
|
||
}
|
||
|
||
function formatDateTime(dateString) {
|
||
if (!dateString) return "N/A";
|
||
try {
|
||
const date = new Date(dateString);
|
||
if (isNaN(date.getTime())) return dateString;
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
const hours = String(date.getHours()).padStart(2, '0');
|
||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||
} catch {
|
||
return dateString;
|
||
}
|
||
}
|
||
|
||
function formatDate(dateString) {
|
||
if (!dateString) return "N/A";
|
||
try {
|
||
return new Date(dateString).toLocaleDateString('zh-CN');
|
||
} catch {
|
||
return dateString;
|
||
}
|
||
}
|
||
|
||
function formatCurrency(amount) {
|
||
if (amount === null || amount === undefined) return "¥0.00";
|
||
return "¥" + Number(amount).toFixed(2);
|
||
}
|
||
|
||
let notificationId = 0;
|
||
const notificationDedup = new Map();
|
||
|
||
function addNotification(message, type = 'info') {
|
||
const dedupKey = `${type}:${message}`;
|
||
const now = Date.now();
|
||
const lastAt = notificationDedup.get(dedupKey) || 0;
|
||
if (now - lastAt < 2500) return;
|
||
notificationDedup.set(dedupKey, now);
|
||
|
||
const id = ++notificationId;
|
||
const notification = { id, message, type, timestamp: new Date(), visible: true };
|
||
appState.notifications.push(notification);
|
||
|
||
setTimeout(() => {
|
||
const index = appState.notifications.findIndex(n => n.id === id);
|
||
if (index > -1) {
|
||
appState.notifications[index].visible = false;
|
||
setTimeout(() => {
|
||
const idx = appState.notifications.findIndex(n => n.id === id);
|
||
if (idx > -1) appState.notifications.splice(idx, 1);
|
||
}, 300);
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
function handleApiError(error, context = '') {
|
||
console.error(`API错误 [${context}]:`, error);
|
||
const message = error.message || '请求失败,请稍后重试';
|
||
addNotification(message, 'error');
|
||
return message;
|
||
}
|
||
|
||
async function parseErrorMessage(response) {
|
||
try {
|
||
const contentType = response.headers.get('content-type') || '';
|
||
if (contentType.includes('application/json')) {
|
||
const body = await response.json().catch(() => null);
|
||
if (body?.detail) {
|
||
if (Array.isArray(body.detail)) {
|
||
const lines = body.detail
|
||
.map((e) => {
|
||
const loc = Array.isArray(e?.loc) ? e.loc.join('.') : '';
|
||
const msg = e?.msg ? String(e.msg) : '校验失败';
|
||
return loc ? `${loc}: ${msg}` : msg;
|
||
})
|
||
.filter(Boolean);
|
||
return lines.length ? lines.join('\n') : '请求校验失败';
|
||
}
|
||
if (typeof body.detail === 'object') return JSON.stringify(body.detail);
|
||
return String(body.detail);
|
||
}
|
||
if (body?.message) return String(body.message);
|
||
return '请求失败';
|
||
}
|
||
const text = await response.text().catch(() => '');
|
||
const normalized = (text || '').trim();
|
||
if (!normalized) return '请求失败';
|
||
return normalized.length > 200 ? normalized.slice(0, 200) + '...' : normalized;
|
||
} catch {
|
||
return '请求失败';
|
||
}
|
||
}
|
||
|
||
async function apiRequest(url, options = {}) {
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
...options.headers
|
||
};
|
||
|
||
if (appState.token) {
|
||
headers['Authorization'] = `Bearer ${appState.token}`;
|
||
}
|
||
|
||
const response = await fetch(url, { ...options, headers });
|
||
|
||
if (response.status === 401) {
|
||
appState.user = null;
|
||
appState.token = null;
|
||
localStorage.removeItem('token');
|
||
localStorage.removeItem('user');
|
||
throw new Error('登录已过期,请重新登录');
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const message = await parseErrorMessage(response);
|
||
throw new Error(message);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
function saveAuth(token, user) {
|
||
appState.token = token;
|
||
appState.user = user;
|
||
localStorage.setItem('token', token);
|
||
localStorage.setItem('user', JSON.stringify(user));
|
||
}
|
||
|
||
function clearAuth() {
|
||
appState.token = null;
|
||
appState.user = null;
|
||
localStorage.removeItem('token');
|
||
localStorage.removeItem('user');
|
||
}
|
||
|
||
function initAuth() {
|
||
const token = localStorage.getItem('token');
|
||
const userStr = localStorage.getItem('user');
|
||
|
||
if (token && userStr) {
|
||
try {
|
||
appState.token = token;
|
||
appState.user = JSON.parse(userStr);
|
||
} catch {
|
||
clearAuth();
|
||
}
|
||
}
|
||
appState.initialized = true;
|
||
}
|
||
|
||
const App = {
|
||
setup() {
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
|
||
const menuItems = computed(() => {
|
||
const items = [
|
||
{ path: '/', label: '首页', icon: '⌂' },
|
||
{ path: '/inventory', label: '进销存', icon: '⊞' },
|
||
{ path: '/moldinsight', label: 'MoldInsight', icon: '◈' }
|
||
];
|
||
|
||
if (appState.user?.is_superuser) {
|
||
items.push({ path: '/users', label: '用户管理', icon: '👤' });
|
||
}
|
||
|
||
return items;
|
||
});
|
||
|
||
const isActive = (path) => {
|
||
if (path === '/') return route.path === '/';
|
||
return route.path.startsWith(path);
|
||
};
|
||
|
||
const handleLogout = async () => {
|
||
try {
|
||
await apiRequest('/api/auth/logout', { method: 'POST' });
|
||
} catch {}
|
||
clearAuth();
|
||
addNotification('已退出登录', 'success');
|
||
router.push('/login');
|
||
};
|
||
|
||
onMounted(() => {
|
||
initAuth();
|
||
});
|
||
|
||
const getPriorityText = (priority) => {
|
||
const priorityMap = {
|
||
'critical': '紧急',
|
||
'high': '高',
|
||
'medium': '中',
|
||
'low': '低'
|
||
};
|
||
return priorityMap[priority] || priority;
|
||
};
|
||
|
||
return {
|
||
route,
|
||
router,
|
||
appState,
|
||
menuItems,
|
||
isActive,
|
||
handleLogout,
|
||
getPriorityText,
|
||
dismissNotification: (id) => {
|
||
const index = appState.notifications.findIndex(n => n.id === id);
|
||
if (index > -1) {
|
||
appState.notifications[index].visible = false;
|
||
setTimeout(() => {
|
||
const idx = appState.notifications.findIndex(n => n.id === id);
|
||
if (idx > -1) appState.notifications.splice(idx, 1);
|
||
}, 300);
|
||
}
|
||
}
|
||
};
|
||
},
|
||
template: `
|
||
<div class="app-container">
|
||
<div class="notification-container" v-if="appState.notifications.length > 0">
|
||
<TransitionGroup name="notification">
|
||
<div
|
||
v-for="notification in appState.notifications"
|
||
:key="notification.id"
|
||
:class="['notification', 'notification-' + notification.type]"
|
||
>
|
||
<div class="notification-icon">
|
||
{{ notification.type === 'success' ? '✓' : notification.type === 'error' ? '✕' : notification.type === 'warning' ? '!' : 'i' }}
|
||
</div>
|
||
<div class="notification-content">
|
||
<div class="notification-message">{{ notification.message }}</div>
|
||
</div>
|
||
<button class="notification-close" @click="dismissNotification(notification.id)">×</button>
|
||
</div>
|
||
</TransitionGroup>
|
||
</div>
|
||
|
||
<header class="app-header">
|
||
<div class="header-content">
|
||
<div class="logo" @click="router.push('/')">
|
||
<div class="logo-icon">G</div>
|
||
<div>
|
||
<div class="logo-text">Gemold</div>
|
||
<div class="logo-subtitle">模具制造管理系统</div>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="nav-menu" v-if="appState.user">
|
||
<router-link
|
||
v-for="item in menuItems"
|
||
:key="item.path"
|
||
:to="item.path"
|
||
:class="['nav-item', { active: isActive(item.path) }]"
|
||
>
|
||
{{ item.label }}
|
||
</router-link>
|
||
</nav>
|
||
|
||
<div class="user-section">
|
||
<template v-if="appState.user">
|
||
<div class="user-info">
|
||
<div class="user-avatar">{{ (appState.user.full_name || appState.user.username).charAt(0).toUpperCase() }}</div>
|
||
<span class="user-name">{{ appState.user.full_name || appState.user.username }}</span>
|
||
</div>
|
||
<button class="btn btn-secondary btn-sm" @click="handleLogout">退出</button>
|
||
</template>
|
||
<template v-else>
|
||
<router-link to="/login" class="btn btn-primary btn-sm">登录</router-link>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="main-content">
|
||
<router-view v-slot="{ Component }">
|
||
<transition name="fade" mode="out-in">
|
||
<component :is="Component" />
|
||
</transition>
|
||
</router-view>
|
||
</main>
|
||
|
||
<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">
|
||
<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 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}` : '当前复杂度信息不足',
|
||
summary.has_hydraulic ? '规则分析提示可能涉及液压抽芯' : '未发现液压抽芯硬性提示'
|
||
].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 cleanedLlmReport = computed(() => stripEmbeddedSideActionAi(state.task?.llm_report || ''));
|
||
const hasVisibleLlmReport = computed(() => Boolean(cleanedLlmReport.value));
|
||
const llmReportHtml = computed(() => renderMarkdownToHtml(cleanedLlmReport.value));
|
||
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: 'engineering-summary', label: '工程摘要' },
|
||
{ id: 'ai-side-action', label: 'AI倒扣分析' },
|
||
{ id: 'dfm-check', label: 'DFM' },
|
||
{ id: 'export-cam', label: '导出/CAM' },
|
||
{ 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
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 downloadUrl = `/api/export-download/${file.filepath.replace(/\\/g, '/').split('/').slice(-2).join('/')}`;
|
||
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,
|
||
hasVisibleLlmReport,
|
||
dfmLevelSummary,
|
||
llmReportHtml,
|
||
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()">← 返回</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">
|
||
<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">{{ formatSchemeDirection(selectedScheme) }}</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">{{ formatNumber(selectedScheme?.score || 0) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">方案可信度</span>
|
||
<span class="info-value">{{ formatNumber(selectedScheme?.confidence_score || 0) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">主要理由</span>
|
||
<span class="info-value">{{ selectedScheme?.summary || primaryRecommendation?.description || '基于当前几何与制造约束自动推荐' }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">高优先级建议</span>
|
||
<span class="info-value">{{ primaryRecommendation?.description || '未发现高优先级工艺建议' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-card result-card-highlight">
|
||
<div class="summary-header">
|
||
<h3>关键操作</h3>
|
||
<span :class="['badge', selectedRiskLabel === '高风险' ? 'badge-error' : selectedRiskLabel === '中风险' ? 'badge-warning' : 'badge-success']">
|
||
{{ selectedRiskLabel }}
|
||
</span>
|
||
</div>
|
||
<div class="info-list">
|
||
<div class="info-item">
|
||
<span class="info-label">DFM 风险数</span>
|
||
<span class="info-value">{{ selectedDfmCount }} 项</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 class="info-item">
|
||
<span class="info-label">下一步</span>
|
||
<span class="info-value">{{ selectedDfmCount ? '先处理 DFM 风险,再确认导出或 CAM' : '可进入 3D 复核、导出与 CAM 准备' }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="state.task.status === 'completed'" class="export-buttons export-buttons-block">
|
||
<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>
|
||
<button class="btn-sm btn-primary" :disabled="state.camLoading" @click="generateCamPlan()" title="基于当前选中方案生成CAM工艺计划">
|
||
{{ state.camLoading ? '生成中...' : '生成 CAM 计划' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-grid compact-metrics-grid">
|
||
<div class="result-card">
|
||
<h3>任务信息</h3>
|
||
<div class="info-list">
|
||
<div class="info-item">
|
||
<span class="info-label">文件大小</span>
|
||
<span class="info-value">{{ formatFileSize(state.task.file_size) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">分析时间</span>
|
||
<span class="info-value">{{ formatDateTime(state.task.completed_at) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">状态</span>
|
||
<span class="info-value">{{ state.task.status }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-card">
|
||
<h3>几何概览</h3>
|
||
<div class="info-list">
|
||
<div class="info-item" v-if="state.task.mesh_summary">
|
||
<span class="info-label">顶点数</span>
|
||
<span class="info-value">{{ formatNumber(state.task.mesh_summary.vertex_count) }}</span>
|
||
</div>
|
||
<div class="info-item" v-if="state.task.mesh_summary">
|
||
<span class="info-label">面数</span>
|
||
<span class="info-value">{{ formatNumber(state.task.mesh_summary.face_count) }}</span>
|
||
</div>
|
||
<div class="info-item" v-if="state.task.mesh_summary || state.task.geometry_data">
|
||
<span class="info-label">边数</span>
|
||
<span class="info-value">{{ formatNumber(state.task.geometry_data?.topology?.edge_count || state.task.geometry_data?.topology?.edges || state.task.mesh_summary?.edge_count || 'N/A') }}</span>
|
||
</div>
|
||
<div class="info-item" v-if="state.task.geometry_data">
|
||
<span class="info-label">体积</span>
|
||
<span class="info-value">{{ formatNumber(state.task.geometry_data.volume) }} mm³</span>
|
||
</div>
|
||
<div class="info-item" v-if="state.task.geometry_data">
|
||
<span class="info-label">表面积</span>
|
||
<span class="info-value">{{ formatNumber(state.task.geometry_data.surface_area) }} mm²</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div 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" class="viewer-section">
|
||
<h3>候选分模方案</h3>
|
||
<div class="inline-note">先切换并确认推荐方案,再进入后续工程判断、导出与 CAM。</div>
|
||
<div :class="['result-grid', hasSingleScheme ? 'single-scheme-grid' : '']">
|
||
<div
|
||
v-for="scheme in candidateSchemes"
|
||
:key="scheme.scheme_id"
|
||
:class="['result-card', hasSingleScheme ? 'single-scheme-card' : '']"
|
||
:style="state.selectedSchemeId === scheme.scheme_id ? 'border: 2px solid var(--primary-color);' : ''"
|
||
>
|
||
<div class="summary-header">
|
||
<h4>{{ scheme.title || scheme.scheme_id }}</h4>
|
||
<span class="badge badge-info">总分 {{ formatNumber(scheme.score) }}</span>
|
||
</div>
|
||
<div class="info-list">
|
||
<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.priority_score || 0) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">法向匹配度</span>
|
||
<span class="info-value">{{ formatNumber(scheme.normal_alignment_score || 0) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">方法</span>
|
||
<span class="info-value">{{ scheme.method || 'rule_based' }}</span>
|
||
</div>
|
||
<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">{{ formatNumber(scheme.score_breakdown?.manufacturability || 0) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">分型质量</span>
|
||
<span class="info-value">{{ formatNumber(scheme.score_breakdown?.parting_quality || 0) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">风险得分</span>
|
||
<span class="info-value">{{ formatNumber(scheme.score_breakdown?.risk || 0) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">DFM 违规项</span>
|
||
<span class="info-value">{{ scheme.dfm_violation_count || 0 }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">方案可信度</span>
|
||
<span class="info-value">{{ formatNumber(scheme.confidence_score || 0) }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">回退标记</span>
|
||
<span class="info-value">{{ scheme.is_fallback ? '是' : '否' }}</span>
|
||
</div>
|
||
<div v-if="scheme.fallback_reason" class="info-item">
|
||
<span class="info-label">回退原因</span>
|
||
<span class="info-value">{{ scheme.fallback_reason }}</span>
|
||
</div>
|
||
</div>
|
||
<button v-if="candidateSchemes.length > 1" class="btn-sm btn-primary" @click="selectScheme(scheme.scheme_id)">查看此方案</button>
|
||
<div v-else class="inline-note">当前仅生成 1 套可用分模方案</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<details v-if="candidateSchemes.length > 1" class="viewer-section diagnostic-panel">
|
||
<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>
|
||
<th>分型质量</th>
|
||
<th>倒扣数</th>
|
||
<th>锁模力</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="scheme in candidateSchemes" :key="'cmp-' + scheme.scheme_id">
|
||
<td>{{ scheme.title || scheme.scheme_id }}</td>
|
||
<td>{{ formatSchemeDirection(scheme) }}</td>
|
||
<td>{{ scheme.offset_label || '中面' }}</td>
|
||
<td>{{ formatNumber(scheme.score) }}</td>
|
||
<td>{{ formatNumber(scheme.confidence_score || 0) }}</td>
|
||
<td>{{ scheme.is_fallback ? '是' : '否' }}</td>
|
||
<td>{{ formatNumber(scheme.normal_alignment_score || 0) }}</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="engineering-summary" v-if="selectedCavityData || selectedKeyInfo" class="viewer-section">
|
||
<h3>工程摘要</h3>
|
||
<div class="result-grid">
|
||
<div class="result-card">
|
||
<h4>方案与零件</h4>
|
||
<div class="info-list">
|
||
<div class="info-item">
|
||
<span class="info-label">零件名称</span>
|
||
<span class="info-value">{{ selectedCavityData?.metadata?.part_name || selectedKeyInfo?.metadata?.part_name || 'N/A' }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">材料</span>
|
||
<span class="info-value">{{ selectedCavityData?.metadata?.material || selectedKeyInfo?.metadata?.material || 'N/A' }}</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>
|
||
</div>
|
||
<div class="result-card" v-if="selectedCavityData?.mold_cavities || selectedKeyInfo?.mold_cavities">
|
||
<h4>型腔与锁模</h4>
|
||
<div class="info-list">
|
||
<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">{{ selectedCavityData?.manufacturing_info?.estimated_clamping_force || '自动计算' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="result-card" v-if="selectedInjectionSystem">
|
||
<h4>注塑模系统</h4>
|
||
<div class="info-list">
|
||
<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 class="info-item">
|
||
<span class="info-label">冷却时间</span>
|
||
<span class="info-value">{{ selectedInjectionSystem?.cooling?.cooling_time || 'N/A' }} s</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">水路数量</span>
|
||
<span class="info-value">{{ selectedInjectionSystem?.cooling?.thermal_check?.channel_count || 0 }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">冷却流量</span>
|
||
<span class="info-value">{{ selectedInjectionSystem?.cooling?.flow_rate?.flow_rate_lpm || 'N/A' }} L/min</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">浇口类型</span>
|
||
<span class="info-value">{{ selectedInjectionSystem?.gating?.gate_type || 'N/A' }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">流道形式</span>
|
||
<span class="info-value">{{ selectedInjectionSystem?.gating?.runner?.type || 'N/A' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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">{{ sideActionAiAdvice.summary || '当前任务未返回更多摘要。' }}</div>
|
||
</div>
|
||
|
||
<div class="result-card">
|
||
<h4>判断依据</h4>
|
||
<div class="recommendations-list" v-if="sideActionAiAdvice.reasons.length">
|
||
<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 class="inline-note" v-else>当前任务未返回明确的判断依据。</div>
|
||
</div>
|
||
|
||
<div class="result-card">
|
||
<h4>标准化建议</h4>
|
||
<div class="recommendations-list" v-if="sideActionAiAdvice.standardAdvice.length">
|
||
<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 class="inline-note" v-else>当前任务未返回额外标准建议。</div>
|
||
</div>
|
||
|
||
<div class="result-card">
|
||
<h4>人工复核项</h4>
|
||
<div class="recommendations-list" v-if="sideActionAiAdvice.manualReviewItems.length">
|
||
<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 class="inline-note" v-else>当前任务无需额外人工复核项。</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="dfm-check" class="viewer-section">
|
||
<h3>DFM 检查</h3>
|
||
<div class="result-grid" v-if="selectedDfmViolations.length">
|
||
<div class="result-card">
|
||
<h4>风险概览</h4>
|
||
<div class="info-list">
|
||
<div class="info-item">
|
||
<span class="info-label">总问题数</span>
|
||
<span class="info-value">{{ selectedDfmViolations.length }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">高风险</span>
|
||
<span class="info-value">{{ dfmLevelSummary.critical + dfmLevelSummary.high }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">中风险</span>
|
||
<span class="info-value">{{ dfmLevelSummary.medium }}</span>
|
||
</div>
|
||
<div class="info-item">
|
||
<span class="info-label">低风险</span>
|
||
<span class="info-value">{{ dfmLevelSummary.low }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="result-card">
|
||
<h4>重点判断</h4>
|
||
<div class="recommendations-list">
|
||
<div class="recommendation-item high" v-if="dfmLevelSummary.critical + dfmLevelSummary.high > 0">
|
||
<div class="recommendation-text">当前方案存在较高工艺风险,建议优先调整壁厚、倒扣或分型方式后再进入制造准备。</div>
|
||
</div>
|
||
<div class="recommendation-item medium" v-else>
|
||
<div class="recommendation-text">当前方案未发现显著高风险项,可结合 3D 预览与 AI 倒扣分析继续做工程评审。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="!selectedDfmViolations.length" class="inline-alert inline-alert-warning">
|
||
<div class="inline-alert-title">未发现显著 DFM 违规项</div>
|
||
<div class="inline-alert-message">当前选中方案未命中高风险规则,可进入下一步工艺评审。</div>
|
||
</div>
|
||
<div v-else class="dfm-card-list">
|
||
<div class="dfm-card" v-for="(item, idx) in selectedDfmViolations" :key="'dfm-' + idx" :class="'dfm-level-' + (item.level || 'medium')">
|
||
<div class="dfm-card-header">
|
||
<div class="mechanism-title">{{ item.rule || 'DFM 规则' }}</div>
|
||
<span class="priority-badge" :class="(item.level || 'medium').toLowerCase()">{{ getPriorityText((item.level || 'medium').toLowerCase()) }}</span>
|
||
</div>
|
||
<div class="dfm-card-message">{{ item.message || 'N/A' }}</div>
|
||
<div class="dfm-card-hint">建议结合上方 3D 预览与 AI 倒扣分析定位具体风险区域。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="export-cam" class="viewer-section">
|
||
<h3>导出与 CAM</h3>
|
||
<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>
|
||
</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="hasVisibleLlmReport">
|
||
<h3>LLM 设计报告</h3>
|
||
<div class="result-card">
|
||
<div class="markdown-preview" v-html="llmReportHtml"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
};
|
||
|
||
const InventoryView = {
|
||
setup() {
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
const deliveryDateInput = ref(null);
|
||
const expectedDateInput = ref(null);
|
||
const deliveryDateNativeInput = ref(null);
|
||
const expectedDateNativeInput = ref(null);
|
||
const state = reactive({
|
||
activeTab: 'dashboard',
|
||
backendDbReady: true,
|
||
backendDbMessage: '',
|
||
productCategory: 'finished',
|
||
dashboard: null,
|
||
financeSummary: null,
|
||
financePeriod: {
|
||
year: new Date().getFullYear(),
|
||
quarter: ''
|
||
},
|
||
financeTransactions: [],
|
||
receivables: [],
|
||
payables: [],
|
||
customerFinanceStatement: [],
|
||
supplierFinanceStatement: [],
|
||
customerProductStatement: [],
|
||
supplierProductStatement: [],
|
||
products: [],
|
||
materials: [],
|
||
finishedProducts: [],
|
||
purchaseOrders: [],
|
||
purchaseWarehouseId: null,
|
||
purchaseReceiveItems: [],
|
||
productionOrders: [],
|
||
productionPlan: null,
|
||
productionWarehouseId: null,
|
||
suppliers: [],
|
||
customers: [],
|
||
warehouses: [],
|
||
inventory: [],
|
||
movements: [],
|
||
loading: false,
|
||
showModal: false,
|
||
modalType: '',
|
||
editingItem: null,
|
||
productBomItems: [],
|
||
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">×</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">×</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">×</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");
|