修改布局主题

This commit is contained in:
zzh 2025-12-08 11:20:28 +08:00
parent 8edea28f69
commit b4ab6be6b8
11 changed files with 1594 additions and 117 deletions

View File

@ -1,12 +1,43 @@
:root{--bg:#0f1216;--surface:#151922;--surface-2:#1b2030;--primary:#4f8cff;--primary-600:#3b71db;--text:#e6e8ee;--text-2:#b3b8c6;--border:#2a3244;--success:#22c55e;--warning:#f59e0b;--danger:#ef4444}
[data-theme="light"]{--bg:#f5f7fa;--surface:#ffffff;--surface-2:#f8f9fb;--primary:#4f8cff;--primary-600:#3b71db;--text:#1a1d23;--text-2:#6b7280;--border:#e5e7eb;--success:#22c55e;--warning:#f59e0b;--danger:#ef4444}
:root{--bg:#0f1216;--surface:#151922;--surface-2:#1b2030;--primary:#4f8cff;--primary-600:#3b71db;--text:#e6e8ee;--text-2:#b3b8c6;--border:#2a3244;--success:#22c55e;--warning:#f59e0b;--danger:#ef4444;--info:#60a5fa;--info-bg:#1e3a5f;--success-bg:#14532d;--warning-bg:#422006}
[data-theme="light"]{--bg:#f5f7fa;--surface:#ffffff;--surface-2:#f8f9fb;--primary:#4f8cff;--primary-600:#3b71db;--text:#1a1d23;--text-2:#6b7280;--border:#e5e7eb;--success:#22c55e;--warning:#f59e0b;--danger:#ef4444;--info:#2196f3;--info-bg:#e3f2fd;--success-bg:#e8f5e9;--warning-bg:#fff3e0}
*{box-sizing:border-box}
html,body{height:100%;overflow:hidden}
body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Noto Sans",sans-serif;line-height:1.5}
#app{display:flex;height:100vh;overflow:hidden}
.sidebar{width:240px;min-width:240px;background:linear-gradient(180deg,var(--surface),var(--surface-2));border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh;overflow:hidden}
.brand{padding:16px 20px;font-weight:700;font-size:18px;color:var(--text)}
.nav{flex:1;overflow-y:auto;overflow-x:hidden;padding:8px 8px 16px}
.sidebar{position:relative;width:240px;min-width:240px;background:linear-gradient(180deg,var(--surface),var(--surface-2));border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh;overflow:visible;transition:width 0.3s ease,min-width 0.3s ease}
.sidebar.collapsed{width:60px;min-width:60px}
.brand-container{position:relative;display:flex;align-items:center;justify-content:space-between;padding:12px 16px;gap:8px}
.loader-wrapper{position:relative;display:flex;align-items:center;gap:10px;height:40px;width:auto;user-select:none;flex:1;min-width:0}
.brand-logo{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--primary) 0%,var(--primary-600) 100%);border-radius:8px;flex-shrink:0;box-shadow:0 2px 8px rgba(79,140,255,0.3)}
.brand-logo svg{width:18px;height:18px;color:#fff}
.brand-logo.dollar-spinner{border-top:2px solid #eab308;background:#fde047;animation:spin 1s linear infinite;border-radius:50%;color:#a16207;font-weight:700;font-size:16px}
@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
.brand-text{display:flex;flex-direction:column;line-height:1.2;overflow:hidden}
.brand-title{font-size:15px;font-weight:700;color:var(--text);letter-spacing:0.5px;white-space:nowrap}
.brand-subtitle{font-size:10px;color:var(--text-2);letter-spacing:1px;text-transform:uppercase;font-weight:500;white-space:nowrap}
[data-theme="light"] .brand-title{color:#1a1d23}
[data-theme="light"] .brand-subtitle{color:#6b7280}
.sidebar.collapsed .brand-container{padding:8px;flex-direction:column;gap:8px}
.sidebar.collapsed .loader-wrapper{flex:0;justify-content:center}
.sidebar.collapsed .brand-text{display:none}
.sidebar.collapsed .brand-logo{width:40px;height:40px;border-radius:10px}
.sidebar.collapsed .brand-logo svg{width:22px;height:22px}
.sidebar.collapsed .brand-logo.dollar-spinner{border-radius:50%;font-size:20px}
.sidebar.collapsed .sidebar-toggle{width:40px;height:32px;background:rgba(79,140,255,0.08);border-radius:8px}
.sidebar-toggle{width:36px;height:36px;border:none;background:transparent;color:var(--text-2);cursor:pointer;border-radius:8px;display:flex;align-items:center;justify-content:center;transition:all 0.2s ease;flex-shrink:0}
.sidebar-toggle:hover{background:rgba(79,140,255,.12);color:var(--text)}
.sidebar-toggle svg{width:20px;height:20px;transition:transform 0.3s ease}
.sidebar.collapsed .sidebar-toggle svg{transform:rotate(180deg)}
.sidebar.collapsed .nav-group-title{opacity:0;height:0;padding:0;margin:0;overflow:hidden}
.sidebar.collapsed .nav-item .text,.sidebar.collapsed .nav-item-btn .text,.sidebar.collapsed .caret{display:none}
.sidebar.collapsed .nav-item{justify-content:center;padding:10px 0;margin:4px 8px}
.sidebar.collapsed .nav-item .icon{width:24px;font-size:18px}
.sidebar.collapsed .nav-item-btn{justify-content:center;padding:10px 0;margin:4px 8px}
.sidebar.collapsed .nav-item-btn .icon{width:24px;font-size:18px}
.sidebar.collapsed .nav-children{display:none}
.sidebar.collapsed .theme-toggle-container{padding:4px;overflow:hidden;justify-content:center}
.sidebar.collapsed .bb8-toggle{transform:scale(0.5);transform-origin:center center}
.nav{flex:1;overflow-y:auto;overflow-x:hidden;padding:8px 8px 16px;min-height:0}
.nav-group{margin:8px 0}
.nav-group-title{padding:8px 12px;color:var(--text-2);font-size:12px;letter-spacing:.04em;text-transform:uppercase}
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 12px;margin:4px;border-radius:8px;color:var(--text);text-decoration:none;font-size:14px}
@ -25,12 +56,7 @@ body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,system-ui
.caret{margin-left:auto;transition:transform .2s ease}
.caret.rotate{transform:rotate(90deg)}
.theme-toggle-container{padding:16px;border-top:1px solid var(--border);margin-top:auto}
.theme-toggle-btn{width:100%;display:flex;align-items:center;justify-content:center;padding:10px;background:transparent;border:1px solid var(--border);border-radius:8px;cursor:pointer;transition:all 0.2s ease}
.theme-toggle-btn:hover{background:rgba(79,140,255,.12);border-color:var(--primary)}
.theme-icon{width:20px;height:20px;filter:invert(1);transition:transform 0.3s ease}
[data-theme="light"] .theme-icon{filter:invert(0)}
.theme-toggle-btn:hover .theme-icon{transform:rotate(20deg)}
.theme-toggle-container{padding:16px;border-top:1px solid var(--border);margin-top:auto;display:flex;justify-content:center}
.btn{border:0;border-radius:8px;padding:8px 12px;font-weight:600;cursor:pointer;background:var(--primary);color:#ffffff}
.btn:hover{background:var(--primary-600)}
@ -1014,10 +1040,32 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover{
}
.export-field-item input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
accent-color: var(--primary);
display: none;
}
.export-field-item .field-checkmark {
width: 18px;
height: 18px;
border: 2px solid var(--border);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface);
transition: all 0.3s;
flex-shrink: 0;
}
.export-field-item .field-checkmark::before {
content: "\2713";
font-size: 12px;
color: transparent;
font-weight: 600;
}
.export-field-item input[type="checkbox"]:checked + .field-checkmark {
background-color: var(--primary);
border-color: var(--primary);
}
.export-field-item input[type="checkbox"]:checked + .field-checkmark::before {
color: #fff;
}
.export-field-item span {
@ -1078,3 +1126,244 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover{
align-items: center;
gap: 12px;
}
/* Brand shine animation */
@-moz-keyframes shine {
0% { background-position: 0; }
60% { background-position: 180px; }
100% { background-position: 180px; }
}
@-webkit-keyframes shine {
0% { background-position: 0; }
60% { background-position: 180px; }
100% { background-position: 180px; }
}
@-o-keyframes shine {
0% { background-position: 0; }
60% { background-position: 180px; }
100% { background-position: 180px; }
}
@keyframes shine {
0% { background-position: 0; }
60% { background-position: 180px; }
100% { background-position: 180px; }
}
/* Metrics Card Styles */
.metrics-card {
padding: 1rem;
background-color: var(--surface);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-radius: 20px;
border: 1px solid var(--border);
}
.metrics-title {
display: flex;
align-items: center;
}
.metrics-icon {
position: relative;
padding: 0.5rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.metrics-icon svg {
color: #ffffff;
height: 1.25rem;
width: 1.25rem;
}
.metrics-title-text {
margin: 0;
margin-left: 0.5rem;
color: var(--text);
font-size: 14px;
font-weight: 600;
}
.metrics-percent {
margin: 0;
margin-left: auto;
font-weight: 600;
display: flex;
align-items: center;
}
.metrics-data {
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.metrics-value {
margin: 0.75rem 0;
color: var(--text);
font-size: 1.75rem;
line-height: 2rem;
font-weight: 700;
text-align: left;
}
.metrics-range {
position: relative;
background-color: var(--border);
width: 100%;
height: 0.5rem;
border-radius: 0.25rem;
}
.metrics-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: 0.25rem;
transition: width 0.3s ease;
}
/* Custom Checkbox Styles */
.custom-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
}
.custom-checkbox input[type="checkbox"] {
display: none;
}
.custom-checkbox .checkmark {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface);
transition: background-color 0.3s, border-color 0.3s, transform 0.3s;
transform-style: preserve-3d;
}
.custom-checkbox .checkmark::before {
content: "\2713";
font-size: 14px;
color: transparent;
transition: color 0.3s, transform 0.3s;
font-weight: 600;
}
.custom-checkbox input[type="checkbox"]:checked + .checkmark {
background-color: var(--primary);
border-color: var(--primary);
transform: scale(1.05) rotateZ(360deg);
}
.custom-checkbox input[type="checkbox"]:checked + .checkmark::before {
color: #fff;
}
.custom-checkbox:hover .checkmark {
border-color: var(--primary);
background-color: rgba(79, 140, 255, 0.1);
transform: scale(1.05);
}
.custom-checkbox input[type="checkbox"]:focus + .checkmark {
box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.2);
outline: none;
}
[data-theme="light"] .custom-checkbox .checkmark {
border-color: #d1d5db;
background: #fff;
}
[data-theme="light"] .custom-checkbox:hover .checkmark {
border-color: var(--primary);
background-color: rgba(79, 140, 255, 0.08);
}
/* Truck Loader Animation */
.truck-loader {
width: 200px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
transform: scale(0.18);
transform-origin: center center;
}
.truck-wrapper {
width: 200px;
height: 100px;
display: flex;
flex-direction: column;
position: relative;
align-items: center;
justify-content: flex-end;
overflow-x: hidden;
}
.truck-body {
width: 130px;
height: fit-content;
margin-bottom: 6px;
animation: truck-motion 1s linear infinite;
}
.truck-body .trucksvg {
width: 100%;
height: auto;
}
@keyframes truck-motion {
0% { transform: translateY(0px); }
50% { transform: translateY(3px); }
100% { transform: translateY(0px); }
}
.truck-tires {
width: 130px;
height: fit-content;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0px 10px 0px 15px;
position: absolute;
bottom: 0;
}
.truck-tires .tiresvg {
width: 24px;
}
.truck-road {
width: 100%;
height: 1.5px;
background-color: #282828;
position: relative;
bottom: 0;
align-self: flex-end;
border-radius: 3px;
}
.truck-road::before {
content: "";
position: absolute;
width: 20px;
height: 100%;
background-color: #282828;
right: -50%;
border-radius: 3px;
animation: truck-road-anim 1.4s linear infinite;
border-left: 10px solid white;
}
.truck-road::after {
content: "";
position: absolute;
width: 10px;
height: 100%;
background-color: #282828;
right: -65%;
border-radius: 3px;
animation: truck-road-anim 1.4s linear infinite;
border-left: 4px solid white;
}
@keyframes truck-road-anim {
0% { transform: translateX(0px); }
100% { transform: translateX(-350px); }
}
.truck-lamp {
position: absolute;
bottom: 0;
right: -90%;
height: 500px;
animation: truck-road-anim 1.4s linear infinite;
fill: #282828;
}

View File

@ -7,13 +7,28 @@
<link rel="icon" type="image/svg+xml" href="./assets/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./assets/styles.css?v=20251122" />
<link rel="stylesheet" href="./assets/mod.css" />
</head>
<body>
<div id="app">
<aside class="sidebar">
<div class="brand">韬智生产管理系统</div>
<aside class="sidebar" id="sidebar">
<div class="brand-container">
<div class="loader-wrapper">
<div class="brand-logo dollar-spinner">$</div>
<div class="brand-text">
<span class="brand-title">韬智生产管理</span>
<span class="brand-subtitle">Production System</span>
</div>
</div>
<button id="sidebar-toggle" class="sidebar-toggle" title="收起菜单">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="11 17 6 12 11 7"></polyline>
<polyline points="18 17 13 12 18 7"></polyline>
</svg>
</button>
</div>
<nav class="nav">
<div class="nav-group">
<div class="nav-group-title">概览</div>
@ -99,9 +114,17 @@
<span class="caret"></span>
</button>
<div class="nav-children" data-parent="plan-mgmt">
<a href="#/plan-mgmt/material-purchase" class="nav-child" data-route="plan-mgmt-material-purchase">
<a href="#/plan-mgmt/bom" class="nav-child" data-route="plan-mgmt-bom">
<span class="child-icon">📦</span>
<span>BOM物料清单</span>
</a>
<a href="#/plan-mgmt/initial-stock" class="nav-child" data-route="plan-mgmt-initial-stock">
<span class="child-icon">🏭</span>
<span>期初库存</span>
</a>
<a href="#/plan-mgmt/purchase-demand" class="nav-child" data-route="plan-mgmt-purchase-demand">
<span class="child-icon">🛒</span>
<span>物料清单-采购</span>
<span>采购需求清单</span>
</a>
<a href="#/plan-mgmt/customer-order" class="nav-child" data-route="plan-mgmt-customer-order">
<span class="child-icon">📋</span>
@ -159,9 +182,39 @@
</div>
</nav>
<div class="theme-toggle-container">
<button id="theme-toggle-btn" class="theme-toggle-btn" title="切换主题">
<img id="theme-icon" class="theme-icon" src="./assets/sun.svg" alt="主题切换" />
</button>
<label class="bb8-toggle">
<input id="theme-toggle-checkbox" class="bb8-toggle__checkbox" type="checkbox">
<div class="bb8-toggle__container">
<div class="bb8-toggle__scenery">
<div class="bb8-toggle__star"></div>
<div class="bb8-toggle__star"></div>
<div class="bb8-toggle__star"></div>
<div class="bb8-toggle__star"></div>
<div class="bb8-toggle__star"></div>
<div class="bb8-toggle__star"></div>
<div class="bb8-toggle__star"></div>
<div class="tatto-1"></div>
<div class="tatto-2"></div>
<div class="gomrassen"></div>
<div class="hermes"></div>
<div class="chenini"></div>
<div class="bb8-toggle__cloud"></div>
<div class="bb8-toggle__cloud"></div>
<div class="bb8-toggle__cloud"></div>
</div>
<div class="bb8">
<div class="bb8__head-container">
<div class="bb8__antenna"></div>
<div class="bb8__antenna"></div>
<div class="bb8__head"></div>
</div>
<div class="bb8__body"></div>
</div>
<div class="artificial__hidden">
<div class="bb8__shadow"></div>
</div>
</div>
</label>
</div>
</aside>
<main class="content">
@ -228,7 +281,9 @@
<script src="./js/components/qa.js"></script>
<script src="./js/components/production.js"></script>
<script src="./js/components/work-order.js"></script>
<script src="./js/components/material-purchase.js"></script>
<script src="./js/components/bom.js"></script>
<script src="./js/components/initial-stock.js"></script>
<script src="./js/components/purchase-demand.js"></script>
<script src="./js/components/customer-order.js"></script>
<script src="./js/components/reconciliation.js"></script>
<script src="./js/components/export.js"></script>

View File

@ -114,6 +114,12 @@ const API = (() => {
setTimeout(() => t.classList.remove('show'), 2000);
}
return {
// 通用HTTP方法
get: (path) => request(path.replace('/api', '')),
post: (path, data) => request(path.replace('/api', ''), { method: 'POST', body: JSON.stringify(data) }),
put: (path, data) => request(path.replace('/api', ''), { method: 'PUT', body: JSON.stringify(data) }),
delete: (path) => request(path.replace('/api', ''), { method: 'DELETE' }),
login: (username, password) => request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
me: () => request('/auth/me'),
logout: () => request('/auth/logout', { method: 'POST' }),

View File

@ -184,28 +184,19 @@
});
// 主题切换
const themeToggleBtn = document.getElementById('theme-toggle-btn');
const themeIcon = document.getElementById('theme-icon');
const themeToggleCheckbox = document.getElementById('theme-toggle-checkbox');
// 更新主题图标
function updateThemeIcon(theme) {
if (themeIcon) {
themeIcon.src = theme === 'dark' ? './assets/sun.svg' : './assets/moon.svg';
themeIcon.alt = theme === 'dark' ? '切换到浅色模式' : '切换到深色模式';
}
// 初始化 checkbox 状态dark = checked
if (themeToggleCheckbox) {
themeToggleCheckbox.checked = savedTheme === 'dark';
}
// 初始化主题图标
updateThemeIcon(savedTheme);
// 主题切换按钮点击事件
themeToggleBtn?.addEventListener('click', () => {
const currentTheme = localStorage.getItem('theme') || 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
// 主题切换事件
themeToggleCheckbox?.addEventListener('change', () => {
const newTheme = themeToggleCheckbox.checked ? 'dark' : 'light';
localStorage.setItem('theme', newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
updateThemeIcon(newTheme);
API.toast(`已切换到${newTheme === 'dark' ? '深色' : '浅色'}主题`);

View File

@ -1,9 +1,44 @@
const Dashboard = (() => {
const truckIcon = `<div class="truck-loader"><div class="truck-wrapper"><div class="truck-body"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 198 93" class="trucksvg"><path stroke-width="3" stroke="#282828" fill="#F83D3D" d="M135 22.5H177.264C178.295 22.5 179.22 23.133 179.594 24.0939L192.33 56.8443C192.442 57.1332 192.5 57.4404 192.5 57.7504V89C192.5 90.3807 191.381 91.5 190 91.5H135C133.619 91.5 132.5 90.3807 132.5 89V25C132.5 23.6193 133.619 22.5 135 22.5Z"></path><path stroke-width="3" stroke="#282828" fill="#7D7C7C" d="M146 33.5H181.741C182.779 33.5 183.709 34.1415 184.078 35.112L190.538 52.112C191.16 53.748 189.951 55.5 188.201 55.5H146C144.619 55.5 143.5 54.3807 143.5 53V36C143.5 34.6193 144.619 33.5 146 33.5Z"></path><path stroke-width="2" stroke="#282828" fill="#282828" d="M150 65C150 65.39 149.763 65.8656 149.127 66.2893C148.499 66.7083 147.573 67 146.5 67C145.427 67 144.501 66.7083 143.873 66.2893C143.237 65.8656 143 65.39 143 65C143 64.61 143.237 64.1344 143.873 63.7107C144.501 63.2917 145.427 63 146.5 63C147.573 63 148.499 63.2917 149.127 63.7107C149.763 64.1344 150 64.61 150 65Z"></path><rect stroke-width="2" stroke="#282828" fill="#FFFCAB" rx="1" height="7" width="5" y="63" x="187"></rect><rect stroke-width="2" stroke="#282828" fill="#282828" rx="1" height="11" width="4" y="81" x="193"></rect><rect stroke-width="3" stroke="#282828" fill="#DFDFDF" rx="2.5" height="90" width="121" y="1.5" x="6.5"></rect><rect stroke-width="2" stroke="#282828" fill="#DFDFDF" rx="2" height="4" width="6" y="84" x="1"></rect></svg></div><div class="truck-tires"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30" class="tiresvg"><circle stroke-width="3" stroke="#282828" fill="#282828" r="13.5" cy="15" cx="15"></circle><circle fill="#DFDFDF" r="7" cy="15" cx="15"></circle></svg><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30" class="tiresvg"><circle stroke-width="3" stroke="#282828" fill="#282828" r="13.5" cy="15" cx="15"></circle><circle fill="#DFDFDF" r="7" cy="15" cx="15"></circle></svg></div><div class="truck-road"></div><svg class="truck-lamp" viewBox="0 0 453.459 453.459" xmlns="http://www.w3.org/2000/svg"><path d="M252.882,0c-37.781,0-68.686,29.953-70.245,67.358h-6.917v8.954c-26.109,2.163-45.463,10.011-45.463,19.366h9.993c-1.65,5.146-2.507,10.54-2.507,16.017c0,28.956,23.558,52.514,52.514,52.514c28.956,0,52.514-23.558,52.514-52.514c0-5.478-0.856-10.872-2.506-16.017h9.992c0-9.354-19.352-17.204-45.463-19.366v-8.954h-6.149C200.189,38.779,223.924,16,252.882,16c29.952,0,54.32,24.368,54.32,54.32c0,28.774-11.078,37.009-25.105,47.437c-17.444,12.968-37.216,27.667-37.216,78.884v113.914h-0.797c-5.068,0-9.174,4.108-9.174,9.177c0,2.844,1.293,5.383,3.321,7.066c-3.432,27.933-26.851,95.744-8.226,115.459v11.202h45.75v-11.202c18.625-19.715-4.794-87.527-8.227-115.459c2.029-1.683,3.322-4.223,3.322-7.066c0-5.068-4.107-9.177-9.176-9.177h-0.795V196.641c0-43.174,14.942-54.283,30.762-66.043c14.793-10.997,31.559-23.461,31.559-60.277C323.202,31.545,291.656,0,252.882,0z M232.77,111.694c0,23.442-19.071,42.514-42.514,42.514c-23.442,0-42.514-19.072-42.514-42.514c0-5.531,1.078-10.957,3.141-16.017h78.747C231.693,100.736,232.77,106.162,232.77,111.694z"></path></svg></div></div>`;
const metricsIcons = {
'直通良品率': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
'良品率': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>',
'发货数量': truckIcon,
'不良数量': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>'
};
function metricsCard(title, value, badgeClass) {
return `<div class="card" style="padding:10px">
<div style="display:flex;flex-direction:column;gap:6px;align-items:center;text-align:center">
<div style="font-weight:600;font-size:12px;color:var(--text-2);white-space:nowrap">${title}</div>
<div class="badge ${badgeClass}" style="font-size:16px;font-weight:700;padding:6px 12px">${value}</div>
const colors = {
success: { bg: '#10B981', text: '#02972f' },
warning: { bg: '#F59E0B', text: '#B45309' },
danger: { bg: '#EF4444', text: '#DC2626' }
};
const color = colors[badgeClass] || colors.success;
const isPercent = String(value).includes('%');
const numValue = parseFloat(String(value).replace('%', '')) || 0;
const fillWidth = isPercent ? Math.min(numValue, 100) : Math.min((numValue / 100000) * 100, 100);
const icon = metricsIcons[title] || metricsIcons['良品率'];
const isTruck = title === '发货数量';
const iconBg = isTruck ? '#f5f5f5' : color.bg;
return `<div class="metrics-card">
<div class="metrics-title">
<span class="metrics-icon" style="background-color:${iconBg}">
${icon}
</span>
<p class="metrics-title-text">${title}</p>
<p class="metrics-percent" style="color:${color.text}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1792 1792" fill="currentColor" height="16" width="16">
<path d="M1408 1216q0 26-19 45t-45 19h-896q-26 0-45-19t-19-45 19-45l448-448q19-19 45-19t45 19l448 448q19 19 19 45z"></path>
</svg>
</p>
</div>
<div class="metrics-data">
<p class="metrics-value">${value}</p>
<div class="metrics-range">
<div class="metrics-fill" style="width:${fillWidth}%;background-color:${color.bg}"></div>
</div>
</div>
</div>`;
}
@ -44,6 +79,8 @@ const Dashboard = (() => {
window.removeEventListener('themeChanged', window.__dashboardThemeHandler);
window.__dashboardThemeHandler = null;
}
// 清理重绘函数引用
window.__redrawTrendChart = null;
// 清理全局变量
window.__auditBusy = false;
window.__pddParams = null;
@ -65,8 +102,8 @@ const Dashboard = (() => {
const data = dRes.status==='fulfilled' ? dRes.value : { goodRate: '—', shipments: '—', defects: '—', badCount: '—' };
const pdd = pRes.status==='fulfilled' ? pRes.value : { list: [] };
const yt = yRes.status==='fulfilled' ? yRes.value : { list: [] };
const pddList = (pdd.list||[]).map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const ytList = (yt.list||[]).map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const pddList = (pdd.list||[]).map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/pdd.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const ytList = (yt.list||[]).map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/yt.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
setTimeout(()=>{
// 清理旧的定时器和事件
if(window.__auditTimer){
@ -122,7 +159,11 @@ const Dashboard = (() => {
if(window.__dashboardResizeHandler){
window.removeEventListener('resize', window.__dashboardResizeHandler);
}
window.__dashboardResizeHandler = () => setAuditHeight();
window.__dashboardResizeHandler = () => {
setAuditHeight();
// 重绘趋势图
if(window.__redrawTrendChart) window.__redrawTrendChart();
};
window.addEventListener('resize', window.__dashboardResizeHandler);
window.__pddParams = window.__pddParams || {};
@ -153,6 +194,9 @@ const Dashboard = (() => {
};
};
// 当前时间维度day, week, month
window.__trendTimeRange = window.__trendTimeRange || 'day';
// 绘制趋势图
let chartData = null;
const drawTrendChart = (pddData, ytData) => {
@ -161,6 +205,7 @@ const Dashboard = (() => {
const ctx = canvas.getContext('2d');
const rect = canvas.parentElement.getBoundingClientRect();
const colors = getThemeColors();
const timeRange = window.__trendTimeRange || 'day';
// 处理高分辨率屏幕,避免字体模糊
const dpr = window.devicePixelRatio || 1;
@ -170,33 +215,78 @@ const Dashboard = (() => {
canvas.style.height = rect.height + 'px';
ctx.scale(dpr, dpr);
// 获取最近30天的日期
const days = [];
for(let i = 29; i >= 0; i--){
const d = new Date();
d.setDate(d.getDate() - i);
days.push(d.toISOString().split('T')[0]);
}
// 根据时间维度生成时间点
const getTimePoints = () => {
const points = [];
const now = new Date();
if(timeRange === 'day') {
// 最近30天
for(let i = 29; i >= 0; i--){
const d = new Date(now);
d.setDate(d.getDate() - i);
points.push(d.toISOString().split('T')[0]);
}
} else if(timeRange === 'week') {
// 最近12周
for(let i = 11; i >= 0; i--){
const d = new Date(now);
d.setDate(d.getDate() - i * 7);
const weekStart = new Date(d);
weekStart.setDate(d.getDate() - d.getDay() + 1); // 周一
points.push(weekStart.toISOString().split('T')[0]);
}
} else {
// 最近12个月
for(let i = 11; i >= 0; i--){
const d = new Date(now);
d.setMonth(d.getMonth() - i);
points.push(d.toISOString().slice(0, 7)); // YYYY-MM
}
}
return points;
};
// 统计每天的数据去重MAC地址
const countByDay = (list) => {
const uniqueMacsByDay = {};
days.forEach(d => uniqueMacsByDay[d] = new Set());
const timePoints = getTimePoints();
// 统计数据去重MAC地址
const countByTime = (list) => {
const uniqueMacs = {};
timePoints.forEach(t => uniqueMacs[t] = new Set());
(list||[]).forEach(r => {
if(r.ts_cn && r.mac){
const date = r.ts_cn.split(' ')[0];
if(uniqueMacsByDay[date] !== undefined) {
uniqueMacsByDay[date].add(r.mac);
if(timeRange === 'day') {
if(uniqueMacs[date] !== undefined) {
uniqueMacs[date].add(r.mac);
}
} else if(timeRange === 'week') {
// 找到对应的周
const recordDate = new Date(date);
for(let i = 0; i < timePoints.length; i++){
const weekStart = new Date(timePoints[i]);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
if(recordDate >= weekStart && recordDate <= weekEnd){
uniqueMacs[timePoints[i]].add(r.mac);
break;
}
}
} else {
// 月份
const month = date.slice(0, 7);
if(uniqueMacs[month] !== undefined) {
uniqueMacs[month].add(r.mac);
}
}
}
});
return days.map(d => uniqueMacsByDay[d].size);
return timePoints.map(t => uniqueMacs[t].size);
};
const pddCounts = countByDay(pddData);
const ytCounts = countByDay(ytData);
const pddCounts = countByTime(pddData);
const ytCounts = countByTime(ytData);
const maxCount = Math.max(...pddCounts, ...ytCounts, 1);
// 绘制参数(使用逻辑尺寸而非物理像素)
@ -205,7 +295,7 @@ const Dashboard = (() => {
const chartHeight = rect.height - padding.top - padding.bottom;
// 保存图表数据供鼠标事件使用
chartData = {days, pddCounts, ytCounts, maxCount, padding, chartWidth, chartHeight};
chartData = {timePoints, pddCounts, ytCounts, maxCount, padding, chartWidth, chartHeight, timeRange};
// 清空画布
ctx.fillStyle = colors.bg;
@ -232,23 +322,32 @@ const Dashboard = (() => {
ctx.fillText(value.toString(), padding.left - 8, y + 4);
}
// 绘制X轴标签每5天显示一个
// 绘制X轴标签
ctx.textAlign = 'center';
days.forEach((day, i) => {
if(i % 5 === 0 || i === days.length - 1){
const x = padding.left + (chartWidth / (days.length - 1)) * i;
const label = day.slice(5);
const labelInterval = timeRange === 'day' ? 5 : (timeRange === 'week' ? 2 : 2);
timePoints.forEach((point, i) => {
if(i % labelInterval === 0 || i === timePoints.length - 1){
const x = padding.left + (chartWidth / (timePoints.length - 1)) * i;
let label;
if(timeRange === 'day') {
label = point.slice(5); // MM-DD
} else if(timeRange === 'week') {
label = point.slice(5); // MM-DD (周起始)
} else {
label = point.slice(2); // YY-MM
}
ctx.fillText(label, x, rect.height - 8);
}
});
// 绘制折线
const today = new Date().toISOString().split('T')[0];
const drawLine = (counts, color) => {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
counts.forEach((count, i) => {
const x = padding.left + (chartWidth / (days.length - 1)) * i;
const x = padding.left + (chartWidth / (timePoints.length - 1)) * i;
const y = padding.top + chartHeight - (count / maxCount) * chartHeight;
if(i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
@ -256,13 +355,42 @@ const Dashboard = (() => {
ctx.stroke();
// 绘制数据点
ctx.fillStyle = color;
counts.forEach((count, i) => {
const x = padding.left + (chartWidth / (days.length - 1)) * i;
const x = padding.left + (chartWidth / (timePoints.length - 1)) * i;
const y = padding.top + chartHeight - (count / maxCount) * chartHeight;
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
const isToday = timeRange === 'day' && timePoints[i] === today;
if(isToday) {
// 今日数据点:更大的圆圈 + 外圈光晕
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fillStyle = color.replace(')', ', 0.2)').replace('rgb', 'rgba').replace('#', '');
ctx.fillStyle = color + '33'; // 添加透明度
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
// 白色内圈
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
// 显示今日数值标签
ctx.fillStyle = color;
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(count.toString(), x, y - 14);
} else {
// 普通数据点
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
});
};
@ -306,6 +434,9 @@ const Dashboard = (() => {
// 保存监听器引用以便清理
window.__dashboardThemeHandler = themeChangeHandler;
// 保存重绘函数引用,供 resize 时调用
window.__redrawTrendChart = themeChangeHandler;
// 添加鼠标悬停事件
const canvas = document.getElementById('trend-chart');
const tooltip = document.getElementById('chart-tooltip');
@ -315,13 +446,13 @@ const Dashboard = (() => {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const {days, pddCounts, ytCounts, maxCount, padding, chartWidth, chartHeight} = chartData;
const {timePoints, pddCounts, ytCounts, maxCount, padding, chartWidth, chartHeight, timeRange} = chartData;
// 查找最近的数据点
let nearestIndex = -1;
let minDist = 15;
for(let i = 0; i < days.length; i++){
const x = padding.left + (chartWidth / (days.length - 1)) * i;
for(let i = 0; i < timePoints.length; i++){
const x = padding.left + (chartWidth / (timePoints.length - 1)) * i;
const pddY = padding.top + chartHeight - (pddCounts[i] / maxCount) * chartHeight;
const ytY = padding.top + chartHeight - (ytCounts[i] / maxCount) * chartHeight;
const distPdd = Math.sqrt((mouseX - x) ** 2 + (mouseY - pddY) ** 2);
@ -334,10 +465,13 @@ const Dashboard = (() => {
}
if(nearestIndex >= 0){
const date = days[nearestIndex];
const point = timePoints[nearestIndex];
const pddCount = pddCounts[nearestIndex];
const ytCount = ytCounts[nearestIndex];
tooltip.innerHTML = `<div style="margin-bottom:2px">${date}</div><div style="color:#f59e0b">拼多多: ${pddCount}</div><div style="color:#3b82f6">圆通: ${ytCount}</div>`;
let label = point;
if(timeRange === 'week') label = point + ' 周';
else if(timeRange === 'month') label = point + ' 月';
tooltip.innerHTML = `<div style="margin-bottom:2px">${label}</div><div style="color:#f59e0b">拼多多: ${pddCount}</div><div style="color:#3b82f6">圆通: ${ytCount}</div>`;
tooltip.style.display = 'block';
// 获取提示框宽度以便放在鼠标左侧
@ -353,6 +487,17 @@ const Dashboard = (() => {
};
}
// 时间维度切换事件
const trendRangeSelect = document.getElementById('trend-range-select');
if(trendRangeSelect){
trendRangeSelect.onchange = () => {
window.__trendTimeRange = trendRangeSelect.value;
const pddData = window.__auditCache?.pdd || pdd.list;
const ytData = window.__auditCache?.yt || yt.list;
drawTrendChart(pddData, ytData);
};
}
// 刷新审计列表的函数
const refreshAuditLists = async() => {
if(window.__auditBusy) return;
@ -363,8 +508,8 @@ const Dashboard = (() => {
const sY=toEpoch(window.__ytParams.start), eY=toEpoch(window.__ytParams.end);
const pddView=(pdd2.list||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sP==null||t>=sP)&&(eP==null||t<=eP);});
const ytView=(yt2.list||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sY==null||t>=sY)&&(eY==null||t<=eY);});
const pddEls=pddView.map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const ytEls=ytView.map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const pddEls=pddView.map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/pdd.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const ytEls=ytView.map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/yt.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const p=document.getElementById('audit-pdd'); if(p) p.innerHTML=pddEls;
const y=document.getElementById('audit-yt'); if(y) y.innerHTML=ytEls;
window.__auditBusy=false;
@ -397,8 +542,9 @@ const Dashboard = (() => {
if (t == null) return false;
return (sTime == null || t >= sTime) && (eTime == null || t <= eTime);
});
const platformIcon = platform === 'pdd' ? '<img src="assets/pdd.svg" style="width:16px;height:16px" />' : '<img src="assets/yt.svg" style="width:16px;height:16px" />';
const listHtml = filteredData.slice(0, 100).map(r =>
`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`
`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px">${platformIcon}${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`
).join('') || '<li>暂无数据</li>';
if (auditListEl) auditListEl.innerHTML = listHtml;
};
@ -544,8 +690,8 @@ const Dashboard = (() => {
const sY=toEpoch(window.__ytParams.start), eY=toEpoch(window.__ytParams.end);
const pddView=(window.__auditCache.pdd||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sP==null||t>=sP)&&(eP==null||t<=eP);});
const ytView=(window.__auditCache.yt||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sY==null||t>=sY)&&(eY==null||t<=eY);});
const pddEls=pddView.slice(0, 100).map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const ytEls=ytView.slice(0, 100).map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const pddEls=pddView.slice(0, 100).map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/pdd.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const ytEls=ytView.slice(0, 100).map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/yt.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const p=document.getElementById('audit-pdd'); if(p) p.innerHTML=pddEls;
const y=document.getElementById('audit-yt'); if(y) y.innerHTML=ytEls;
@ -561,8 +707,9 @@ const Dashboard = (() => {
if (t == null) return false;
return (sTime == null || t >= sTime) && (eTime == null || t <= eTime);
});
const platformIcon = platform === 'pdd' ? '<img src="assets/pdd.svg" style="width:16px;height:16px" />' : '<img src="assets/yt.svg" style="width:16px;height:16px" />';
const listHtml = filteredData.slice(0, 100).map(r =>
`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`
`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px">${platformIcon}${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`
).join('') || '<li>暂无数据</li>';
const auditListEl = document.getElementById('audit-list');
if (auditListEl) auditListEl.innerHTML = listHtml;
@ -591,7 +738,14 @@ const Dashboard = (() => {
${metricsCard('不良数量', data.badCount ?? data.defects, 'danger')}
</div>
<div class="card" style="margin-top:12px">
<div style="font-weight:600;margin-bottom:8px">审计趋势最近30天</div>
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
<span>审计趋势</span>
<select id="trend-range-select" style="padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none;cursor:pointer">
<option value="day">按天</option>
<option value="week">按周</option>
<option value="month">按月</option>
</select>
</div>
<div style="height:220px;background:var(--bg);border:1px solid var(--border);border-radius:8px;position:relative">
<canvas id="trend-chart" style="width:100%;height:100%;cursor:crosshair"></canvas>
<div id="chart-tooltip" style="position:absolute;background:rgba(0,0,0,0.85);color:#fff;padding:6px 10px;border-radius:4px;font-size:12px;pointer-events:none;display:none;white-space:nowrap"></div>

View File

@ -54,7 +54,7 @@
<thead>
<tr>
<th style="min-width: 50px; text-align: center;">
<input type="checkbox" id="select-all-checkbox" onchange="MaterialPurchase.toggleSelectAll(this)" />
<label class="custom-checkbox"><input type="checkbox" id="select-all-checkbox" onchange="MaterialPurchase.toggleSelectAll(this)" /><span class="checkmark"></span></label>
</th>
<th style="min-width: 150px;">标题</th>
<th style="min-width: 180px;">生产计划明细物料需求清单编号</th>
@ -112,91 +112,91 @@
</div>
<div class="export-fields-grid">
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="title" data-label="标题" />
<input type="checkbox" class="export-field-checkbox" value="title" data-label="标题" /><span class="field-checkmark"></span>
<span>标题</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="listNo" data-label="生产计划明细物料需求清单编号" />
<input type="checkbox" class="export-field-checkbox" value="listNo" data-label="生产计划明细物料需求清单编号" /><span class="field-checkmark"></span>
<span>生产计划明细物料需求清单编号</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="planNo" data-label="生产计划编号" />
<input type="checkbox" class="export-field-checkbox" value="planNo" data-label="生产计划编号" /><span class="field-checkmark"></span>
<span>生产计划编号</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="bomResult" data-label="产品BOM分析结果" />
<input type="checkbox" class="export-field-checkbox" value="bomResult" data-label="产品BOM分析结果" /><span class="field-checkmark"></span>
<span>产品BOM分析结果</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="status" data-label="状态" />
<input type="checkbox" class="export-field-checkbox" value="status" data-label="状态" /><span class="field-checkmark"></span>
<span>状态</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="demandStatus" data-label="需求状态" />
<input type="checkbox" class="export-field-checkbox" value="demandStatus" data-label="需求状态" /><span class="field-checkmark"></span>
<span>需求状态</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="completeRate" data-label="物料齐套率" />
<input type="checkbox" class="export-field-checkbox" value="completeRate" data-label="物料齐套率" /><span class="field-checkmark"></span>
<span>物料齐套率</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="materialCode" data-label="物料编码" />
<input type="checkbox" class="export-field-checkbox" value="materialCode" data-label="物料编码" /><span class="field-checkmark"></span>
<span>物料编码</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="materialName" data-label="物料名称" />
<input type="checkbox" class="export-field-checkbox" value="materialName" data-label="物料名称" /><span class="field-checkmark"></span>
<span>物料名称</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="batchNo" data-label="物料批次号" />
<input type="checkbox" class="export-field-checkbox" value="batchNo" data-label="物料批次号" /><span class="field-checkmark"></span>
<span>物料批次号</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="level" data-label="物料层级" />
<input type="checkbox" class="export-field-checkbox" value="level" data-label="物料层级" /><span class="field-checkmark"></span>
<span>物料层级</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="requiredQty" data-label="所需物料数" />
<input type="checkbox" class="export-field-checkbox" value="requiredQty" data-label="所需物料数" /><span class="field-checkmark"></span>
<span>所需物料数</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="stockQty" data-label="库存现有物料数" />
<input type="checkbox" class="export-field-checkbox" value="stockQty" data-label="库存现有物料数" /><span class="field-checkmark"></span>
<span>库存现有物料数</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="shortage" data-label="欠缺值" />
<input type="checkbox" class="export-field-checkbox" value="shortage" data-label="欠缺值" /><span class="field-checkmark"></span>
<span>欠缺值</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="acquireMethod" data-label="物料获取方式" />
<input type="checkbox" class="export-field-checkbox" value="acquireMethod" data-label="物料获取方式" /><span class="field-checkmark"></span>
<span>物料获取方式</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="realtimeStock" data-label="实时库存值" />
<input type="checkbox" class="export-field-checkbox" value="realtimeStock" data-label="实时库存值" /><span class="field-checkmark"></span>
<span>实时库存值</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="pendingQty" data-label="待入库数量" />
<input type="checkbox" class="export-field-checkbox" value="pendingQty" data-label="待入库数量" /><span class="field-checkmark"></span>
<span>待入库数量</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="dispatchedQty" data-label="派发数量" />
<input type="checkbox" class="export-field-checkbox" value="dispatchedQty" data-label="派发数量" /><span class="field-checkmark"></span>
<span>派发数量</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="receivedQty" data-label="入库数量" />
<input type="checkbox" class="export-field-checkbox" value="receivedQty" data-label="入库数量" /><span class="field-checkmark"></span>
<span>入库数量</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="submitter" data-label="提交人" />
<input type="checkbox" class="export-field-checkbox" value="submitter" data-label="提交人" /><span class="field-checkmark"></span>
<span>提交人</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="submitTime" data-label="提交时间" />
<input type="checkbox" class="export-field-checkbox" value="submitTime" data-label="提交时间" /><span class="field-checkmark"></span>
<span>提交时间</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="updateTime" data-label="更新时间" />
<input type="checkbox" class="export-field-checkbox" value="updateTime" data-label="更新时间" /><span class="field-checkmark"></span>
<span>更新时间</span>
</label>
</div>
@ -535,9 +535,9 @@
tbody.innerHTML = pageData.map(item => `
<tr>
<td style="text-align: center;">
<input type="checkbox" class="row-checkbox" value="${item.id}"
<label class="custom-checkbox"><input type="checkbox" class="row-checkbox" value="${item.id}"
${this.selectedIds.has(item.id) ? 'checked' : ''}
onchange="MaterialPurchase.toggleRowSelection(${item.id}, this.checked)" />
onchange="MaterialPurchase.toggleRowSelection(${item.id}, this.checked)" /><span class="checkmark"></span></label>
</td>
<td>${this.escapeHtml(item.title)}</td>
<td>${this.escapeHtml(item.listNo)}</td>

View File

@ -20,7 +20,7 @@
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" id="select-all-reconciliation" style="cursor: pointer;" />
<label class="custom-checkbox"><input type="checkbox" id="select-all-reconciliation" /><span class="checkmark"></span></label>
</th>
<th>序号</th>
<th>下单时间</th>
@ -297,7 +297,7 @@
return `
<tr>
<td>
<input type="checkbox" class="reconciliation-checkbox" data-id="${item.id}" style="cursor: pointer;" onchange="window.Reconciliation.updateBatchDeleteButton()" />
<label class="custom-checkbox"><input type="checkbox" class="reconciliation-checkbox" data-id="${item.id}" onchange="window.Reconciliation.updateBatchDeleteButton()" /><span class="checkmark"></span></label>
</td>
<td>${index + 1}</td>
<td>${item.order_date || '—'}</td>

View File

@ -126,8 +126,8 @@ Router.register('/settings', async () => {
<div style="padding:20px;background:var(--surface);border-radius:8px;border:1px solid var(--border)">
<div style="font-weight:600;margin-bottom:12px;font-size:15px">💧 水印设置</div>
<div style="display:flex;align-items:center;gap:12px;padding:16px;background:var(--bg);border:1px solid var(--border);border-radius:6px">
<label style="flex:1;cursor:pointer;display:flex;align-items:center;gap:12px">
<input type="checkbox" id="watermark-toggle" ${localStorage.getItem('watermarkEnabled') !== 'false' ? 'checked' : ''} style="width:20px;height:20px;cursor:pointer" />
<label class="custom-checkbox" style="flex:1;cursor:pointer;display:flex;align-items:center;gap:12px">
<input type="checkbox" id="watermark-toggle" ${localStorage.getItem('watermarkEnabled') !== 'false' ? 'checked' : ''} /><span class="checkmark"></span>
<span style="font-size:15px;font-weight:500">启用页面水印显示用户名和时间戳</span>
</label>
</div>

View File

@ -1,4 +1,5 @@
(() => {
// 展开/收起子菜单
document.querySelectorAll('.nav-item.has-children').forEach(group => {
const btn = group.querySelector('.nav-item-btn');
const caret = btn.querySelector('.caret');
@ -8,4 +9,31 @@
caret.classList.toggle('rotate');
});
});
// 侧边栏折叠功能
const sidebar = document.getElementById('sidebar');
const toggleBtn = document.getElementById('sidebar-toggle');
if (sidebar && toggleBtn) {
// 从 localStorage 恢复状态
const isCollapsed = localStorage.getItem('sidebar-collapsed') === 'true';
if (isCollapsed) {
sidebar.classList.add('collapsed');
}
toggleBtn.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
const collapsed = sidebar.classList.contains('collapsed');
localStorage.setItem('sidebar-collapsed', collapsed);
toggleBtn.title = collapsed ? '展开菜单' : '收起菜单';
// 触发窗口 resize 事件,让图表立即重绘
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 310); // 等待侧边栏动画完成 (300ms)
});
// 设置初始 title
toggleBtn.title = sidebar.classList.contains('collapsed') ? '展开菜单' : '收起菜单';
}
})();

View File

@ -84,7 +84,9 @@ const Router = (() => {
'production-mgmt': '生产管理',
'work-order': '生产工单下发中心',
'plan-mgmt': '计划管理',
'material-purchase': '物料清单-采购',
'bom': 'BOM物料清单',
'initial-stock': '期初库存',
'purchase-demand': '采购需求清单',
'customer-order': '客户订单',
'reconciliation': '对账单',
export: '导出',

View File

@ -227,6 +227,61 @@ def init_db():
c.execute('ALTER TABLE work_orders ADD COLUMN product_model TEXT')
except Exception:
pass # 列已存在
# BOM物料清单表 - 定义产品的物料组成
c.execute('''CREATE TABLE IF NOT EXISTS bom(
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_code TEXT NOT NULL,
product_name TEXT NOT NULL,
material_code TEXT NOT NULL,
material_name TEXT NOT NULL,
unit_qty REAL NOT NULL,
unit TEXT DEFAULT 'pcs',
min_package INTEGER DEFAULT 1,
supplier TEXT,
remark TEXT,
created_by TEXT,
created_at TEXT,
updated_at TEXT
)''')
# 期初库存表 - 记录物料的初始库存
c.execute('''CREATE TABLE IF NOT EXISTS initial_stock(
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_code TEXT NOT NULL UNIQUE,
material_name TEXT NOT NULL,
stock_qty INTEGER DEFAULT 0,
unit TEXT DEFAULT 'pcs',
min_package INTEGER DEFAULT 1,
supplier TEXT,
remark TEXT,
created_by TEXT,
created_at TEXT,
updated_at TEXT
)''')
# 采购需求清单表 - 记录计算后的采购需求
c.execute('''CREATE TABLE IF NOT EXISTS purchase_demand(
id INTEGER PRIMARY KEY AUTOINCREMENT,
demand_no TEXT NOT NULL,
material_code TEXT NOT NULL,
material_name TEXT NOT NULL,
order_qty INTEGER DEFAULT 0,
bom_unit_qty REAL DEFAULT 0,
total_demand INTEGER DEFAULT 0,
initial_stock INTEGER DEFAULT 0,
net_demand INTEGER DEFAULT 0,
min_package INTEGER DEFAULT 1,
actual_purchase_qty INTEGER DEFAULT 0,
unit TEXT DEFAULT 'pcs',
supplier TEXT,
status TEXT DEFAULT 'pending',
remark TEXT,
created_by TEXT,
created_at TEXT,
updated_at TEXT
)''')
conn.commit()
# create default admin
c.execute('SELECT id FROM users WHERE username=?', ('admin',))
@ -4313,6 +4368,903 @@ def upload_shipment():
return jsonify({'error': f'解析发货单失败:{str(e)}'}), 500
# ==================== BOM物料清单 API ====================
@app.get('/api/bom')
@require_login
@require_any_role('superadmin', 'admin')
def get_bom_list():
"""获取BOM列表"""
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM bom ORDER BY product_code, id')
rows = c.fetchall()
conn.close()
return jsonify({'list': [dict(r) for r in rows]})
@app.get('/api/bom/products')
@require_login
@require_any_role('superadmin', 'admin')
def get_bom_products():
"""获取BOM中所有产品列表去重"""
conn = get_db()
c = conn.cursor()
c.execute('SELECT DISTINCT product_code, product_name FROM bom ORDER BY product_code')
rows = c.fetchall()
conn.close()
return jsonify({'list': [dict(r) for r in rows]})
@app.post('/api/bom')
@require_login
@require_any_role('superadmin')
def create_bom():
"""创建BOM条目"""
data = request.get_json() or {}
product_code = (data.get('product_code') or '').strip()
product_name = (data.get('product_name') or '').strip()
material_code = (data.get('material_code') or '').strip()
material_name = (data.get('material_name') or '').strip()
unit_qty = data.get('unit_qty', 1)
unit = (data.get('unit') or 'pcs').strip()
min_package = data.get('min_package', 1)
supplier = (data.get('supplier') or '').strip()
remark = (data.get('remark') or '').strip()
if not product_code or not product_name or not material_code or not material_name:
return jsonify({'error': '产品编码、产品名称、物料编码、物料名称为必填项'}), 400
try:
unit_qty = float(unit_qty)
min_package = int(min_package) if min_package else 1
except (ValueError, TypeError):
return jsonify({'error': '单机用量和最小包装必须是有效数字'}), 400
if unit_qty <= 0:
return jsonify({'error': '单机用量必须大于0'}), 400
conn = get_db()
c = conn.cursor()
now = get_beijing_time()
username = session.get('username', '')
c.execute('''INSERT INTO bom(
product_code, product_name, material_code, material_name, unit_qty,
unit, min_package, supplier, remark, created_by, created_at, updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)''', (
product_code, product_name, material_code, material_name, unit_qty,
unit, min_package, supplier, remark, username, now, now
))
bom_id = c.lastrowid
conn.commit()
conn.close()
log('create_bom', f'产品: {product_code}, 物料: {material_code}')
return jsonify({'ok': True, 'id': bom_id, 'message': 'BOM创建成功'})
@app.put('/api/bom/<int:bom_id>')
@require_login
@require_any_role('superadmin')
def update_bom(bom_id):
"""更新BOM条目"""
data = request.get_json() or {}
product_code = (data.get('product_code') or '').strip()
product_name = (data.get('product_name') or '').strip()
material_code = (data.get('material_code') or '').strip()
material_name = (data.get('material_name') or '').strip()
unit_qty = data.get('unit_qty', 1)
unit = (data.get('unit') or 'pcs').strip()
min_package = data.get('min_package', 1)
supplier = (data.get('supplier') or '').strip()
remark = (data.get('remark') or '').strip()
if not product_code or not product_name or not material_code or not material_name:
return jsonify({'error': '产品编码、产品名称、物料编码、物料名称为必填项'}), 400
try:
unit_qty = float(unit_qty)
min_package = int(min_package) if min_package else 1
except (ValueError, TypeError):
return jsonify({'error': '单机用量和最小包装必须是有效数字'}), 400
conn = get_db()
c = conn.cursor()
c.execute('SELECT id FROM bom WHERE id=?', (bom_id,))
if not c.fetchone():
conn.close()
return jsonify({'error': 'BOM不存在'}), 404
now = get_beijing_time()
c.execute('''UPDATE bom SET
product_code=?, product_name=?, material_code=?, material_name=?, unit_qty=?,
unit=?, min_package=?, supplier=?, remark=?, updated_at=?
WHERE id=?''', (
product_code, product_name, material_code, material_name, unit_qty,
unit, min_package, supplier, remark, now, bom_id
))
conn.commit()
conn.close()
log('update_bom', f'BOM ID: {bom_id}')
return jsonify({'ok': True, 'message': 'BOM更新成功'})
@app.delete('/api/bom/<int:bom_id>')
@require_login
@require_any_role('superadmin')
def delete_bom(bom_id):
"""删除BOM条目"""
conn = get_db()
c = conn.cursor()
c.execute('SELECT product_code, material_code FROM bom WHERE id=?', (bom_id,))
row = c.fetchone()
if not row:
conn.close()
return jsonify({'error': 'BOM不存在'}), 404
c.execute('DELETE FROM bom WHERE id=?', (bom_id,))
conn.commit()
conn.close()
log('delete_bom', f'BOM ID: {bom_id}, 产品: {row["product_code"]}, 物料: {row["material_code"]}')
return jsonify({'ok': True, 'message': 'BOM删除成功'})
@app.post('/api/bom/batch-delete')
@require_login
@require_any_role('superadmin')
def batch_delete_bom():
"""批量删除BOM"""
data = request.get_json() or {}
ids = data.get('ids', [])
if not ids:
return jsonify({'error': '请选择要删除的BOM'}), 400
conn = get_db()
c = conn.cursor()
placeholders = ','.join('?' * len(ids))
c.execute(f'DELETE FROM bom WHERE id IN ({placeholders})', ids)
count = c.rowcount
conn.commit()
conn.close()
log('batch_delete_bom', f'批量删除 {count} 条BOM')
return jsonify({'ok': True, 'count': count, 'message': f'成功删除 {count} 条BOM'})
@app.post('/api/bom/import')
@require_login
@require_any_role('superadmin')
def import_bom():
"""从Excel导入BOM"""
f = request.files.get('file')
if not f:
return jsonify({'error': '请上传文件'}), 400
filename = f.filename.lower()
if not (filename.endswith('.xlsx') or filename.endswith('.xls') or filename.endswith('.csv')):
return jsonify({'error': '请上传Excel或CSV文件'}), 400
try:
import pandas as pd
if filename.endswith('.csv'):
df = pd.read_csv(f)
else:
df = pd.read_excel(f)
# 列名映射(支持中英文)
column_map = {
'产品编码': 'product_code', 'product_code': 'product_code',
'产品名称': 'product_name', 'product_name': 'product_name',
'物料编码': 'material_code', 'material_code': 'material_code',
'物料名称': 'material_name', 'material_name': 'material_name',
'单机用量': 'unit_qty', 'unit_qty': 'unit_qty',
'单位': 'unit', 'unit': 'unit',
'最小包装': 'min_package', 'min_package': 'min_package',
'供应商': 'supplier', 'supplier': 'supplier',
'备注': 'remark', 'remark': 'remark'
}
df = df.rename(columns=column_map)
# 检查必填列
required = ['product_code', 'product_name', 'material_code', 'material_name']
missing = [col for col in required if col not in df.columns]
if missing:
return jsonify({'error': f'缺少必填列: {", ".join(missing)}'}), 400
conn = get_db()
c = conn.cursor()
now = get_beijing_time()
username = session.get('username', '')
success_count = 0
for _, row in df.iterrows():
product_code = str(row.get('product_code', '')).strip()
product_name = str(row.get('product_name', '')).strip()
material_code = str(row.get('material_code', '')).strip()
material_name = str(row.get('material_name', '')).strip()
if not product_code or not material_code or product_code == 'nan' or material_code == 'nan':
continue
unit_qty = 1
try:
unit_qty = float(row.get('unit_qty', 1))
except:
pass
min_package = 1
try:
min_package = int(float(row.get('min_package', 1)))
except:
pass
unit = str(row.get('unit', 'pcs')).strip()
if unit == 'nan':
unit = 'pcs'
supplier = str(row.get('supplier', '')).strip()
if supplier == 'nan':
supplier = ''
remark = str(row.get('remark', '')).strip()
if remark == 'nan':
remark = ''
c.execute('''INSERT INTO bom(
product_code, product_name, material_code, material_name, unit_qty,
unit, min_package, supplier, remark, created_by, created_at, updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)''', (
product_code, product_name, material_code, material_name, unit_qty,
unit, min_package, supplier, remark, username, now, now
))
success_count += 1
conn.commit()
conn.close()
log('import_bom', f'导入 {success_count} 条BOM')
return jsonify({'ok': True, 'count': success_count, 'message': f'成功导入 {success_count} 条BOM'})
except Exception as e:
return jsonify({'error': f'导入失败: {str(e)}'}), 500
# ==================== 期初库存 API ====================
@app.get('/api/initial-stock')
@require_login
@require_any_role('superadmin', 'admin')
def get_initial_stock_list():
"""获取期初库存列表"""
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM initial_stock ORDER BY material_code')
rows = c.fetchall()
conn.close()
return jsonify({'list': [dict(r) for r in rows]})
@app.post('/api/initial-stock')
@require_login
@require_any_role('superadmin')
def create_initial_stock():
"""创建期初库存"""
data = request.get_json() or {}
material_code = (data.get('material_code') or '').strip()
material_name = (data.get('material_name') or '').strip()
stock_qty = data.get('stock_qty', 0)
unit = (data.get('unit') or 'pcs').strip()
min_package = data.get('min_package', 1)
supplier = (data.get('supplier') or '').strip()
remark = (data.get('remark') or '').strip()
if not material_code or not material_name:
return jsonify({'error': '物料编码和物料名称为必填项'}), 400
try:
stock_qty = int(stock_qty)
min_package = int(min_package) if min_package else 1
except (ValueError, TypeError):
return jsonify({'error': '库存数量和最小包装必须是有效整数'}), 400
if stock_qty < 0:
return jsonify({'error': '库存数量不能为负数'}), 400
conn = get_db()
c = conn.cursor()
now = get_beijing_time()
username = session.get('username', '')
# 检查物料编码是否已存在
c.execute('SELECT id FROM initial_stock WHERE material_code=?', (material_code,))
if c.fetchone():
conn.close()
return jsonify({'error': '该物料编码已存在,请使用更新功能'}), 400
c.execute('''INSERT INTO initial_stock(
material_code, material_name, stock_qty, unit, min_package,
supplier, remark, created_by, created_at, updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?)''', (
material_code, material_name, stock_qty, unit, min_package,
supplier, remark, username, now, now
))
stock_id = c.lastrowid
conn.commit()
conn.close()
log('create_initial_stock', f'物料: {material_code}, 库存: {stock_qty}')
return jsonify({'ok': True, 'id': stock_id, 'message': '期初库存创建成功'})
@app.put('/api/initial-stock/<int:stock_id>')
@require_login
@require_any_role('superadmin')
def update_initial_stock(stock_id):
"""更新期初库存"""
data = request.get_json() or {}
material_code = (data.get('material_code') or '').strip()
material_name = (data.get('material_name') or '').strip()
stock_qty = data.get('stock_qty', 0)
unit = (data.get('unit') or 'pcs').strip()
min_package = data.get('min_package', 1)
supplier = (data.get('supplier') or '').strip()
remark = (data.get('remark') or '').strip()
if not material_code or not material_name:
return jsonify({'error': '物料编码和物料名称为必填项'}), 400
try:
stock_qty = int(stock_qty)
min_package = int(min_package) if min_package else 1
except (ValueError, TypeError):
return jsonify({'error': '库存数量和最小包装必须是有效整数'}), 400
conn = get_db()
c = conn.cursor()
c.execute('SELECT id FROM initial_stock WHERE id=?', (stock_id,))
if not c.fetchone():
conn.close()
return jsonify({'error': '期初库存不存在'}), 404
now = get_beijing_time()
c.execute('''UPDATE initial_stock SET
material_code=?, material_name=?, stock_qty=?, unit=?, min_package=?,
supplier=?, remark=?, updated_at=?
WHERE id=?''', (
material_code, material_name, stock_qty, unit, min_package,
supplier, remark, now, stock_id
))
conn.commit()
conn.close()
log('update_initial_stock', f'库存 ID: {stock_id}')
return jsonify({'ok': True, 'message': '期初库存更新成功'})
@app.delete('/api/initial-stock/<int:stock_id>')
@require_login
@require_any_role('superadmin')
def delete_initial_stock(stock_id):
"""删除期初库存"""
conn = get_db()
c = conn.cursor()
c.execute('SELECT material_code FROM initial_stock WHERE id=?', (stock_id,))
row = c.fetchone()
if not row:
conn.close()
return jsonify({'error': '期初库存不存在'}), 404
c.execute('DELETE FROM initial_stock WHERE id=?', (stock_id,))
conn.commit()
conn.close()
log('delete_initial_stock', f'库存 ID: {stock_id}, 物料: {row["material_code"]}')
return jsonify({'ok': True, 'message': '期初库存删除成功'})
@app.post('/api/initial-stock/batch-delete')
@require_login
@require_any_role('superadmin')
def batch_delete_initial_stock():
"""批量删除期初库存"""
data = request.get_json() or {}
ids = data.get('ids', [])
if not ids:
return jsonify({'error': '请选择要删除的库存'}), 400
conn = get_db()
c = conn.cursor()
placeholders = ','.join('?' * len(ids))
c.execute(f'DELETE FROM initial_stock WHERE id IN ({placeholders})', ids)
count = c.rowcount
conn.commit()
conn.close()
log('batch_delete_initial_stock', f'批量删除 {count} 条期初库存')
return jsonify({'ok': True, 'count': count, 'message': f'成功删除 {count} 条期初库存'})
@app.post('/api/initial-stock/import')
@require_login
@require_any_role('superadmin')
def import_initial_stock():
"""从Excel导入期初库存"""
f = request.files.get('file')
if not f:
return jsonify({'error': '请上传文件'}), 400
filename = f.filename.lower()
if not (filename.endswith('.xlsx') or filename.endswith('.xls') or filename.endswith('.csv')):
return jsonify({'error': '请上传Excel或CSV文件'}), 400
try:
import pandas as pd
if filename.endswith('.csv'):
df = pd.read_csv(f)
else:
df = pd.read_excel(f)
# 列名映射(支持中英文)
column_map = {
'物料编码': 'material_code', 'material_code': 'material_code',
'物料名称': 'material_name', 'material_name': 'material_name',
'库存数量': 'stock_qty', 'stock_qty': 'stock_qty',
'单位': 'unit', 'unit': 'unit',
'最小包装': 'min_package', 'min_package': 'min_package',
'供应商': 'supplier', 'supplier': 'supplier',
'备注': 'remark', 'remark': 'remark'
}
df = df.rename(columns=column_map)
# 检查必填列
required = ['material_code', 'material_name']
missing = [col for col in required if col not in df.columns]
if missing:
return jsonify({'error': f'缺少必填列: {", ".join(missing)}'}), 400
conn = get_db()
c = conn.cursor()
now = get_beijing_time()
username = session.get('username', '')
success_count = 0
update_count = 0
for _, row in df.iterrows():
material_code = str(row.get('material_code', '')).strip()
material_name = str(row.get('material_name', '')).strip()
if not material_code or material_code == 'nan':
continue
stock_qty = 0
try:
stock_qty = int(float(row.get('stock_qty', 0)))
except:
pass
min_package = 1
try:
min_package = int(float(row.get('min_package', 1)))
except:
pass
unit = str(row.get('unit', 'pcs')).strip()
if unit == 'nan':
unit = 'pcs'
supplier = str(row.get('supplier', '')).strip()
if supplier == 'nan':
supplier = ''
remark = str(row.get('remark', '')).strip()
if remark == 'nan':
remark = ''
# 检查是否已存在,存在则更新
c.execute('SELECT id FROM initial_stock WHERE material_code=?', (material_code,))
existing = c.fetchone()
if existing:
c.execute('''UPDATE initial_stock SET
material_name=?, stock_qty=?, unit=?, min_package=?,
supplier=?, remark=?, updated_at=?
WHERE material_code=?''', (
material_name, stock_qty, unit, min_package,
supplier, remark, now, material_code
))
update_count += 1
else:
c.execute('''INSERT INTO initial_stock(
material_code, material_name, stock_qty, unit, min_package,
supplier, remark, created_by, created_at, updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?)''', (
material_code, material_name, stock_qty, unit, min_package,
supplier, remark, username, now, now
))
success_count += 1
conn.commit()
conn.close()
log('import_initial_stock', f'新增 {success_count} 条, 更新 {update_count} 条期初库存')
return jsonify({
'ok': True,
'count': success_count + update_count,
'message': f'成功导入 {success_count} 条新记录,更新 {update_count} 条已有记录'
})
except Exception as e:
return jsonify({'error': f'导入失败: {str(e)}'}), 500
# ==================== 采购需求清单 API ====================
@app.get('/api/purchase-demand')
@require_login
@require_any_role('superadmin', 'admin')
def get_purchase_demand_list():
"""获取采购需求清单"""
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM purchase_demand ORDER BY created_at DESC')
rows = c.fetchall()
conn.close()
return jsonify({'list': [dict(r) for r in rows]})
@app.post('/api/purchase-demand/calculate')
@require_login
@require_any_role('superadmin')
def calculate_purchase_demand():
"""计算采购需求
公式: 客户订单数量 * BOM单机数量 - 期初库存 = 净需求
按照最小包装得出实际采购数量
"""
data = request.get_json() or {}
product_code = (data.get('product_code') or '').strip()
order_qty = data.get('order_qty', 0)
if not product_code:
return jsonify({'error': '请选择产品'}), 400
try:
order_qty = int(order_qty)
except (ValueError, TypeError):
return jsonify({'error': '订单数量必须是有效整数'}), 400
if order_qty <= 0:
return jsonify({'error': '订单数量必须大于0'}), 400
conn = get_db()
c = conn.cursor()
# 获取产品的BOM清单
c.execute('SELECT * FROM bom WHERE product_code=?', (product_code,))
bom_list = c.fetchall()
if not bom_list:
conn.close()
return jsonify({'error': f'未找到产品 {product_code} 的BOM清单'}), 404
# 生成需求编号
import math
now = get_beijing_time()
demand_no = 'PD' + datetime.now().strftime('%Y%m%d%H%M%S')
username = session.get('username', '')
results = []
for bom in bom_list:
material_code = bom['material_code']
material_name = bom['material_name']
unit_qty = bom['unit_qty']
min_package = bom['min_package'] or 1
unit = bom['unit']
supplier = bom['supplier']
# 计算总需求 = 订单数量 * 单机用量
total_demand = int(math.ceil(order_qty * unit_qty))
# 获取期初库存
c.execute('SELECT stock_qty, min_package as stock_min_package FROM initial_stock WHERE material_code=?', (material_code,))
stock_row = c.fetchone()
initial_stock = stock_row['stock_qty'] if stock_row else 0
# 如果BOM没有设置最小包装但库存表有使用库存表的
if min_package <= 1 and stock_row and stock_row['stock_min_package']:
min_package = stock_row['stock_min_package']
# 计算净需求 = 总需求 - 期初库存
net_demand = total_demand - initial_stock
if net_demand < 0:
net_demand = 0
# 按最小包装计算实际采购数量
if net_demand > 0 and min_package > 0:
actual_purchase_qty = math.ceil(net_demand / min_package) * min_package
else:
actual_purchase_qty = 0
# 插入采购需求记录
c.execute('''INSERT INTO purchase_demand(
demand_no, material_code, material_name, order_qty, bom_unit_qty,
total_demand, initial_stock, net_demand, min_package, actual_purchase_qty,
unit, supplier, status, created_by, created_at, updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (
demand_no, material_code, material_name, order_qty, unit_qty,
total_demand, initial_stock, net_demand, min_package, actual_purchase_qty,
unit, supplier, 'pending', username, now, now
))
results.append({
'material_code': material_code,
'material_name': material_name,
'order_qty': order_qty,
'bom_unit_qty': unit_qty,
'total_demand': total_demand,
'initial_stock': initial_stock,
'net_demand': net_demand,
'min_package': min_package,
'actual_purchase_qty': actual_purchase_qty,
'unit': unit,
'supplier': supplier
})
conn.commit()
conn.close()
log('calculate_purchase_demand', f'产品: {product_code}, 订单数量: {order_qty}, 生成 {len(results)} 条采购需求')
return jsonify({
'ok': True,
'demand_no': demand_no,
'list': results,
'message': f'成功生成 {len(results)} 条采购需求'
})
@app.post('/api/purchase-demand/calculate-from-orders')
@require_login
@require_any_role('superadmin')
def calculate_purchase_demand_from_orders():
"""从客户订单自动计算采购需求
遍历客户订单根据物料匹配BOM计算采购需求
"""
data = request.get_json() or {}
order_ids = data.get('order_ids', []) # 可选指定订单ID列表
conn = get_db()
c = conn.cursor()
# 获取客户订单
if order_ids:
placeholders = ','.join('?' * len(order_ids))
c.execute(f'SELECT * FROM customer_orders WHERE id IN ({placeholders})', order_ids)
else:
c.execute('SELECT * FROM customer_orders')
orders = c.fetchall()
if not orders:
conn.close()
return jsonify({'error': '未找到客户订单'}), 404
import math
now = get_beijing_time()
demand_no = 'PD' + datetime.now().strftime('%Y%m%d%H%M%S')
username = session.get('username', '')
# 汇总物料需求
material_demands = {} # {material_code: {'total_demand': x, 'material_name': '', ...}}
for order in orders:
material = order['material']
order_qty = order['quantity']
# 尝试通过物料名称匹配BOM中的产品
c.execute('''SELECT * FROM bom WHERE product_name LIKE ? OR product_code LIKE ?''',
(f'%{material}%', f'%{material}%'))
bom_list = c.fetchall()
if bom_list:
# 找到BOM按BOM展开
for bom in bom_list:
mat_code = bom['material_code']
mat_name = bom['material_name']
unit_qty = bom['unit_qty']
min_pkg = bom['min_package'] or 1
unit = bom['unit']
supplier = bom['supplier']
demand = int(math.ceil(order_qty * unit_qty))
if mat_code in material_demands:
material_demands[mat_code]['total_demand'] += demand
else:
material_demands[mat_code] = {
'material_name': mat_name,
'total_demand': demand,
'min_package': min_pkg,
'unit': unit,
'supplier': supplier,
'bom_unit_qty': unit_qty,
'order_qty': order_qty
}
else:
# 没找到BOM直接按物料处理假设1:1
if material in material_demands:
material_demands[material]['total_demand'] += order_qty
else:
material_demands[material] = {
'material_name': material,
'total_demand': order_qty,
'min_package': 1,
'unit': 'pcs',
'supplier': '',
'bom_unit_qty': 1,
'order_qty': order_qty
}
# 计算净需求和实际采购数量
results = []
for mat_code, info in material_demands.items():
# 获取期初库存
c.execute('SELECT stock_qty, min_package FROM initial_stock WHERE material_code=?', (mat_code,))
stock_row = c.fetchone()
initial_stock = stock_row['stock_qty'] if stock_row else 0
min_package = info['min_package']
if min_package <= 1 and stock_row and stock_row['min_package']:
min_package = stock_row['min_package']
total_demand = info['total_demand']
net_demand = total_demand - initial_stock
if net_demand < 0:
net_demand = 0
if net_demand > 0 and min_package > 0:
actual_purchase_qty = math.ceil(net_demand / min_package) * min_package
else:
actual_purchase_qty = 0
# 插入采购需求记录
c.execute('''INSERT INTO purchase_demand(
demand_no, material_code, material_name, order_qty, bom_unit_qty,
total_demand, initial_stock, net_demand, min_package, actual_purchase_qty,
unit, supplier, status, created_by, created_at, updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (
demand_no, mat_code, info['material_name'], info['order_qty'], info['bom_unit_qty'],
total_demand, initial_stock, net_demand, min_package, actual_purchase_qty,
info['unit'], info['supplier'], 'pending', username, now, now
))
results.append({
'material_code': mat_code,
'material_name': info['material_name'],
'total_demand': total_demand,
'initial_stock': initial_stock,
'net_demand': net_demand,
'min_package': min_package,
'actual_purchase_qty': actual_purchase_qty
})
conn.commit()
conn.close()
log('calculate_purchase_demand_from_orders', f'需求编号: {demand_no}, 生成 {len(results)} 条采购需求')
return jsonify({
'ok': True,
'demand_no': demand_no,
'list': results,
'message': f'成功生成 {len(results)} 条采购需求'
})
@app.put('/api/purchase-demand/<int:demand_id>')
@require_login
@require_any_role('superadmin')
def update_purchase_demand(demand_id):
"""更新采购需求状态"""
data = request.get_json() or {}
status = (data.get('status') or '').strip()
remark = (data.get('remark') or '').strip()
if status and status not in ['pending', 'ordered', 'received', 'completed', 'cancelled']:
return jsonify({'error': '无效的状态'}), 400
conn = get_db()
c = conn.cursor()
c.execute('SELECT id FROM purchase_demand WHERE id=?', (demand_id,))
if not c.fetchone():
conn.close()
return jsonify({'error': '采购需求不存在'}), 404
now = get_beijing_time()
updates = ['updated_at=?']
params = [now]
if status:
updates.append('status=?')
params.append(status)
if remark:
updates.append('remark=?')
params.append(remark)
params.append(demand_id)
c.execute(f'UPDATE purchase_demand SET {", ".join(updates)} WHERE id=?', params)
conn.commit()
conn.close()
log('update_purchase_demand', f'需求 ID: {demand_id}')
return jsonify({'ok': True, 'message': '采购需求更新成功'})
@app.delete('/api/purchase-demand/<int:demand_id>')
@require_login
@require_any_role('superadmin')
def delete_purchase_demand(demand_id):
"""删除采购需求"""
conn = get_db()
c = conn.cursor()
c.execute('SELECT demand_no, material_code FROM purchase_demand WHERE id=?', (demand_id,))
row = c.fetchone()
if not row:
conn.close()
return jsonify({'error': '采购需求不存在'}), 404
c.execute('DELETE FROM purchase_demand WHERE id=?', (demand_id,))
conn.commit()
conn.close()
log('delete_purchase_demand', f'需求 ID: {demand_id}')
return jsonify({'ok': True, 'message': '采购需求删除成功'})
@app.post('/api/purchase-demand/batch-delete')
@require_login
@require_any_role('superadmin')
def batch_delete_purchase_demand():
"""批量删除采购需求"""
data = request.get_json() or {}
ids = data.get('ids', [])
if not ids:
return jsonify({'error': '请选择要删除的采购需求'}), 400
conn = get_db()
c = conn.cursor()
placeholders = ','.join('?' * len(ids))
c.execute(f'DELETE FROM purchase_demand WHERE id IN ({placeholders})', ids)
count = c.rowcount
conn.commit()
conn.close()
log('batch_delete_purchase_demand', f'批量删除 {count} 条采购需求')
return jsonify({'ok': True, 'count': count, 'message': f'成功删除 {count} 条采购需求'})
@app.errorhandler(404)
def not_found(e):
# 如果请求的是 HTML 页面(通过 Accept header 判断),返回 index.html