dy-dashboard/static/js/app.js.backup-0630
2026-10-28 11:14:17 +08:00

12931 lines
686 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 蝶雅采购智能体平台 - 前端逻辑
let charts = {};
// 页面缓存:切换菜单时不销毁页面状态
var CACHE_VERSION = 59;
if (!window._pageCache) window._pageCache = {};
if (!window._pageDataCache) window._pageDataCache = {};
if (!window._pageReloaders) window._pageReloaders = {};
if (window._cacheVersion !== CACHE_VERSION) {
window._pageCache = {};
window._pageDataCache = {};
window._pageReloaders = {};
window._cacheVersion = CACHE_VERSION;
}
const PAGE_LOADERS = {
'dashboard': loadDashboard,
'data_detail': loadDataPage,
'data_summary': loadDataSummaryPage,
'data_outbound': loadDataPage,
'products': loadProductsPage,
'users': loadUsersPage,
'logs': loadLogsPage,
'login_records': loadLoginRecordsPage,
'config': loadConfigPage,
'permissions': loadPermissionsPage,
'warehouse': loadWarehousePage,
'inventory': loadInventoryPage,
'inventory_finished': loadInventoryPage,
'inventory_packaging': loadInventoryPage,
'suppliers': loadSuppliersPage,
'planning': loadPlanningPage,
'purchase': loadPurchasePage,
'purchase_ready': loadPurchaseReadyPage,
'orders': loadOrdersPage,
'orders_domestic': loadDomesticOrdersPage,
'orders_overseas': loadOverseasOrdersPage,
'orders_packaging': loadPackagingOrdersPage,
'orders_finished': loadFinishedProductsPage,
'bom': loadBomPage,
'knowledge': loadKnowledgePage,
'feedback': loadFeedbackPage,
'contract_templates': loadContractTemplatesPage
};
// ─── 知识库页面 ───
// ─── 知识库页面 ───
function loadKnowledgePage(container) {
renderKnowledgePage(container);
}
function renderKnowledgePage(container) {
// 配置 marked
if (typeof marked !== 'undefined') {
marked.setOptions({
breaks: true,
gfm: true
});
}
container.innerHTML = `
<style>
.kb-tabs { display:flex;gap:4px;margin-bottom:16px;background:var(--bg-card,#f7f8fa);padding:4px;border-radius:10px; }
.kb-tab { padding:8px 20px;font-size:13px;font-weight:600;cursor:pointer;color:var(--text-light,#888);border-radius:8px;transition:all .25s;user-select:none; }
.kb-tab:hover { color:var(--text,#333);background:rgba(0,0,0,0.04); }
.kb-tab:nth-child(1).active { color:#1890ff;background:#e6f7ff;box-shadow:0 1px 4px rgba(24,144,255,0.12); }
.kb-tab:nth-child(2).active { color:#722ed1;background:#f9f0ff;box-shadow:0 1px 4px rgba(114,46,209,0.12); }
.kb-tab:nth-child(3).active { color:#13c2c2;background:#e6fffb;box-shadow:0 1px 4px rgba(19,194,194,0.12); }
.kb-topics { display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px; }
.kb-topic-card { background:var(--bg-card,#fff);border-radius:8px;border:1px solid var(--border,#e8e8e8);overflow:hidden; }
.kb-topic-header { padding:12px 16px;background:#f9f9f9;border-bottom:1px solid var(--border,#e8e8e8);font-weight:600;font-size:15px;color:var(--text,#333);display:flex;align-items:center;gap:8px; }
.kb-topic-body { padding:8px 12px; }
.kb-article-item { display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-radius:6px;cursor:pointer;text-decoration:none;color:var(--text,#333);transition:background .15s; }
.kb-article-item:hover { background:#f0f5ff; }
.kb-article-title { font-weight:500;font-size:14px; }
.kb-article-summary { font-size:12px;color:var(--text-light,#999);margin-left:6px; }
.kb-article-date { font-size:12px;color:var(--text-light,#999);white-space:nowrap; }
.kb-empty { padding:12px;color:var(--text-light,#999);font-size:13px; }
.kb-article-view { line-height:1.9;font-size:15px;color:#333; }
.kb-article-view h1 { font-size:22px;border-bottom:1px solid #eee;padding-bottom:10px;margin:0 0 16px 0; }
.kb-article-view h2 { font-size:18px;margin:20px 0 10px 0;padding-bottom:6px;border-bottom:1px solid #eee; }
.kb-article-view h3 { font-size:16px;margin:16px 0 8px 0; }
.kb-article-view h4 { font-size:15px;margin:12px 0 6px 0; }
.kb-article-view p { margin:0 0 10px 0; }
.kb-article-view blockquote { border-left:3px solid #1890ff;margin:10px 0;padding:6px 14px;background:#f6f9ff;color:#555; }
.kb-article-view code { background:#f0f0f0;padding:2px 6px;border-radius:3px;font-size:13px;color:#d63384; }
.kb-article-view pre { background:#1e1e2e;padding:14px 18px;border-radius:8px;overflow-x:auto;margin:12px 0; }
.kb-article-view pre code { background:none;color:#cdd6f4;padding:0;font-size:13px;line-height:1.6; }
.kb-article-view table { border-collapse:collapse;width:100%;margin:12px 0;font-size:14px; }
.kb-article-view th { background:#f5f5f5;padding:8px 12px;border:1px solid #e0e0e0;font-weight:600;text-align:left; }
.kb-article-view td { padding:8px 12px;border:1px solid #e0e0e0; }
.kb-article-view tr:hover td { background:#fafafa; }
.kb-article-view a { color:#1890ff;text-decoration:none; }
.kb-article-view a:hover { text-decoration:underline; }
.kb-article-view ul, .kb-article-view ol { padding-left:20px;margin:8px 0; }
.kb-article-view li { margin:4px 0; }
.kb-article-view hr { border:none;border-top:1px solid #eee;margin:16px 0; }
.kb-article-view strong { font-weight:600; }
.kb-section { font-size:15px;color:var(--text-light,#999);padding:8px 0; }
.kb-profile-block { background:var(--bg-card,#fff);border-radius:8px;border:1px solid var(--border,#e8e8e8);margin-bottom:12px;overflow:hidden; }
.kb-profile-hd { padding:10px 16px;font-weight:600;font-size:14px;border-bottom:1px solid var(--border,#e8e8e8);display:flex;align-items:center;gap:8px; }
.kb-profile-bd { padding:12px 16px;line-height:1.8;font-size:14px; }
.kb-profile-bd table { border-collapse:collapse;width:100%; }
.kb-profile-bd td,.kb-profile-bd th { padding:6px 10px;border:1px solid #eee;font-size:13px; }
.kb-profile-bd th { background:#fafafa;font-weight:600; }
</style>
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between">
<h1 style="display:flex;align-items:center;gap:10px"><span style="font-size:28px">📚</span> 知识库</h1>
<div style="display:flex;gap:8px">
<input type="text" id="kbSearchInput" class="form-input" placeholder="搜索文章..." style="width:220px;padding:6px 12px" onkeydown="if(event.key==='Enter')kbSearch()">
<button class="btn btn-sm btn-primary" onclick="kbSearch()" style="padding:6px 14px">搜索</button>
<button class="btn btn-sm" onclick="kbLoadIndex()" style="padding:6px 14px" title="刷新">🔄</button>
</div>
</div>
<!-- 标签页导航 -->
<div class="kb-tabs">
<div class="kb-tab active" id="kbTabWiki" onclick="kbSwitchTab('wiki')">📖 知识库</div>
<div class="kb-tab" id="kbTabDatalib" onclick="kbSwitchTab('datalib')">📁 资料库</div>
<div class="kb-tab" id="kbTabProfile" onclick="kbSwitchTab('profile')">🧑 画像</div>
<div class="kb-tab" id="kbTabAnalysis" onclick="kbSwitchTab('analysis')">🔬 分析</div>
<div style="margin-left:auto;display:flex;gap:4px;padding:4px">
<button class="btn btn-sm" onclick="kbShowCreateModal()" title="新建文章">📝 新建</button>
<button class="btn btn-sm" onclick="kbShowImportModal()" title="导入文件">📥 导入</button>
</div>
</div>
<!-- 知识库面板 -->
<div id="kbPaneWiki">
<div class="card" id="kbMainCard">
<div id="kbContent">
<div style="text-align:center;padding:60px 20px;color:var(--text-light,#999)">
<div style="font-size:48px;margin-bottom:16px">⏳</div>
<div style="font-size:15px">正在加载知识库...</div>
</div>
</div>
</div>
<div class="card" id="kbArticleCard" style="display:none">
<div class="card-header" style="display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--border,#e8e8e8)">
<button class="btn btn-sm" onclick="kbBackToList()" style="padding:4px 12px">← 返回列表</button>
<div style="flex:1">
<h3 id="kbArticleTitle" style="margin:0;font-size:15px;font-weight:600"></h3>
<span id="kbArticleMeta" style="font-size:12px;color:var(--text-light,#999)"></span>
</div>
<div style="display:flex;gap:4px">
<button class="btn btn-sm" id="kbEditBtn" onclick="kbShowEditModal()" title="编辑">✏️ 编辑</button>
<button class="btn btn-sm" id="kbExportBtn" onclick="kbExportArticle()" title="导出">📤 导出</button>
<button class="btn btn-sm" id="kbDeleteBtn" onclick="kbDeleteArticle()" style="color:var(--danger)" title="删除">🗑️ 删除</button>
</div>
</div>
<div class="card-body" id="kbArticleContent" style="padding:20px 24px"></div>
</div>
</div>
<!-- 资料库面板 -->
<div id="kbPaneDatalib" style="display:none">
<div style="text-align:center;padding:60px;color:var(--text-light,#999)">
<div style="font-size:48px;margin-bottom:16px">⏳</div>
<div>正在加载资料库...</div>
</div>
</div>
<!-- 画像面板 -->
<div id="kbPaneProfile" style="display:none">
<div style="text-align:center;padding:60px;color:var(--text-light,#999)">
<div style="font-size:48px;margin-bottom:16px">⏳</div>
<div>正在加载画像...</div>
</div>
</div>
<!-- 分析面板 -->
<div id="kbPaneAnalysis" style="display:none">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<div class="card">
<div class="card-header" style="display:flex;align-items:center;gap:8px;padding:10px 14px">
<h3 style="margin:0;font-size:14px">🧹 知识库检查</h3>
<button class="btn btn-sm btn-primary" onclick="kbRunLint()" style="margin-left:auto;padding:4px 12px">运行检查</button>
</div>
<div class="card-body" id="kbLintResult" style="padding:12px 14px;font-size:13px;line-height:1.7;min-height:120px;color:var(--text-light,#999)">
点击「运行检查」开始
</div>
</div>
<div class="card">
<div class="card-header" style="padding:10px 14px">
<h3 style="margin:0;font-size:14px">⚠️ 矛盾分析</h3>
</div>
<div class="card-body" id="kbContradictionResult" style="padding:12px 14px;font-size:13px;line-height:1.7;min-height:120px;color:var(--text-light,#999)">
暂无分析结果
</div>
</div>
</div>
<div class="card">
<div class="card-header" style="padding:10px 14px">
<h3 style="margin:0;font-size:14px">🔗 关系图谱</h3>
</div>
<div class="card-body" id="kbGraphContainer" style="padding:12px 14px;min-height:300px;display:flex;align-items:center;justify-content:center;color:var(--text-light,#999)">
<div style="text-align:center">
<div style="font-size:40px;margin-bottom:8px">🗺️</div>
<div>点击「加载图谱」查看文章关联</div>
<button class="btn btn-sm btn-primary" onclick="kbLoadGraph()" style="margin-top:8px">加载图谱</button>
</div>
</div>
</div>
</div>
<!-- 新建/编辑文章弹窗 -->
<div class="modal-overlay" id="kbEditorModal" style="display:none">
<div class="modal" style="width:720px">
<div class="modal-header">
<h3 id="kbEditorTitle">✏️ 编辑文章</h3>
<span class="modal-close" onclick="kbCloseEditor()">&times;</span>
</div>
<div class="modal-body" style="padding:16px 20px">
<div style="margin-bottom:10px">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">标题</label>
<input type="text" id="kbEditorArticleTitle" class="form-input" style="width:100%;padding:6px 10px" placeholder="文章标题">
</div>
<div style="margin-bottom:10px">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">主题分类</label>
<select id="kbEditorTopic" class="form-input" style="width:100%;padding:6px 10px"></select>
</div>
<div style="margin-bottom:4px">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">内容(Markdown)</label>
<textarea id="kbEditorContent" class="form-input" style="width:100%;min-height:320px;padding:8px 10px;font-family:monospace;font-size:13px" placeholder="在此编写 Markdown 内容..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="kbCloseEditor()">取消</button>
<button class="btn btn-primary" id="kbEditorSaveBtn" onclick="kbSaveArticle()">保存</button>
</div>
</div>
</div>
<!-- 导入弹窗 -->
<div class="modal-overlay" id="kbImportModal" style="display:none">
<div class="modal" style="width:480px">
<div class="modal-header">
<h3>📥 导入文件</h3>
<span class="modal-close" onclick="document.getElementById('kbImportModal').style.display='none'">&times;</span>
</div>
<div class="modal-body" style="padding:16px 20px">
<div style="margin-bottom:10px">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">目标主题</label>
<select id="kbImportTopic" class="form-input" style="width:100%;padding:6px 10px"></select>
</div>
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">选择 Markdown 文件</label>
<input type="file" id="kbImportFile" accept=".md,.txt,.markdown" class="form-input" style="width:100%;padding:6px">
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="document.getElementById('kbImportModal').style.display='none'">取消</button>
<button class="btn btn-primary" onclick="kbDoImport()">导入</button>
</div>
</div>
</div>
`;
kbLoadIndex();
kbLoadDatalib();
kbLoadProfile();
}
// 标签页切换
window._kbActiveTab = 'wiki';
function kbSwitchTab(tab) {
window._kbActiveTab = tab;
['wiki','datalib','profile'].forEach(function(t) {
document.getElementById('kbTab' + t.charAt(0).toUpperCase() + t.slice(1)).classList.toggle('active', t === tab);
document.getElementById('kbPane' + t.charAt(0).toUpperCase() + t.slice(1)).style.display = t === tab ? '' : 'none';
});
// 切到知识库时回到第一级默认页面
if (tab === 'wiki') {
var mainCard = document.getElementById('kbMainCard');
var articleCard = document.getElementById('kbArticleCard');
if (mainCard) mainCard.style.display = '';
if (articleCard) articleCard.style.display = 'none';
// 如果当前不是卡片视图(比如打开了子主题),刷新回卡片首页
if (!mainCard || !mainCard.querySelector('.kb-topics')) {
kbLoadIndex();
}
}
}
function kbLoadIndex() {
var el = document.getElementById('kbContent');
el.innerHTML = '<div style="text-align:center;padding:60px 20px;color:var(--text-light,#999)"><div style="font-size:48px;margin-bottom:16px">⏳</div><div>正在加载知识库...</div></div>';
// 同时获取索引和操作日志
Promise.all([
fetch('/api/knowledge/index').then(function(r) { return r.json(); }),
fetch('/api/knowledge/log').then(function(r) { return r.json(); })
])
.then(function(results) {
var idxRes = results[0];
var logRes = results[1];
if (idxRes.code !== 0) {
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--danger)">❌ 加载失败</div>';
return;
}
// 解析操作日志,分页显示最近更新
var recentHtml = '';
var logContent = logRes.data || '';
if (logContent) {
var logLines = logContent.split('\n');
var rawEntries = [];
var currentEntry = null;
logLines.forEach(function(line) {
if (line.match(/^## \[/)) {
if (currentEntry) rawEntries.push(currentEntry);
var dateMatch = line.match(/^## \[(\d{4}-\d{2}-\d{2})\]/);
var dateStr = dateMatch ? dateMatch[1] : '';
var text = line.replace(/^## \[\S+\]\s*/, '').trim();
currentEntry = { date: dateStr, text: text, children: [] };
} else if (currentEntry && line.trim().startsWith('- ')) {
currentEntry.children.push(line.trim());
}
});
if (currentEntry) rawEntries.push(currentEntry);
// 按日期分组(每天合并为一条更新)
var dayMap = {};
rawEntries.forEach(function(e) {
if (!e.date) return;
if (!dayMap[e.date]) {
dayMap[e.date] = { date: e.date, actions: [] };
}
dayMap[e.date].actions.push(e.text);
});
var dayEntries = Object.keys(dayMap).sort().map(function(d) { return dayMap[d]; });
// 分页:每页5条(天)
var RECENT_PER_PAGE = 5;
if (!window._kbRecentPage) window._kbRecentPage = 1;
var totalEntries = dayEntries.length;
var totalPages = Math.max(1, Math.ceil(totalEntries / RECENT_PER_PAGE));
var page = Math.min(Math.max(1, window._kbRecentPage || 1), totalPages);
window._kbRecentTotalPages = totalPages;
// 从后往前取(最新的排前面)
var startIdx = Math.max(0, totalEntries - page * RECENT_PER_PAGE);
var endIdx = Math.max(0, totalEntries - (page - 1) * RECENT_PER_PAGE);
var pageEntries = dayEntries.slice(startIdx, endIdx).reverse();
recentHtml = '<div class="card" style="margin-bottom:12px;padding:0" id="kbRecentCard">' +
'<div class="card-header" style="padding:8px 14px;font-size:13px;font-weight:600;border-bottom:1px solid var(--border,#e8e8e8);display:flex;justify-content:space-between;align-items:center;cursor:pointer" onclick="kbToggleRecent()">' +
'<span>🕐 最近更新' + (totalPages > 1 ? ' <span style="font-size:12px;color:#999;font-weight:400">第' + page + '/' + totalPages + '页</span>' : '') + '</span>' +
'<span id="kbRecentToggle" style="font-size:12px;color:var(--text-light,#999);user-select:none">▼ 收起</span>' +
'</div>' +
'<div id="kbRecentBody" style="display:block;max-height:320px;overflow-y:auto">' +
'<div style="padding:6px 14px;font-size:13px;color:var(--text-light,#666);line-height:2.2">';
pageEntries.forEach(function(e, idx) {
var dateId = 'kbDate_' + e.date.replace(/-/g, '_');
recentHtml += '<div style="margin-bottom:4px">';
recentHtml += '<div style="cursor:pointer;display:flex;align-items:center;gap:6px;padding:4px 0" onclick="kbToggleDateActions(\'' + dateId + '\')">';
recentHtml += '<span id="' + dateId + '_arrow" style="font-size:10px;color:#999;transition:transform .2s">▶</span>';
recentHtml += '<strong>📅 ' + escHtml(e.date) + '</strong>';
recentHtml += '<span style="font-size:12px;color:#999">(' + e.actions.length + ' 项操作)</span>';
recentHtml += '</div>';
recentHtml += '<div id="' + dateId + '" style="display:none;padding-left:24px;font-size:12px;color:var(--text-light,#999);line-height:2">';
e.actions.forEach(function(a) {
recentHtml += '<div>• ' + escHtml(a) + '</div>';
});
recentHtml += '</div></div>';
});
recentHtml += '</div>';
// 分页控件
if (totalPages > 1) {
recentHtml += '<div style="display:flex;justify-content:center;gap:3px;padding:6px 8px;border-top:1px solid var(--border,#f0f0f0);flex-wrap:wrap">';
recentHtml += '<button class="kb-page-btn" onclick="kbRecentGoPage(1)" style="font-size:11px;padding:2px 8px;border:1px solid #d9d9d9;border-radius:4px;cursor:pointer;background:#fff' + (page <= 1 ? ';opacity:0.4;cursor:default' : '') + '"' + (page <= 1 ? ' disabled' : '') + '>首页</button>';
recentHtml += '<button class="kb-page-btn" onclick="kbRecentGoPage(' + (page - 1) + ')" style="font-size:11px;padding:2px 8px;border:1px solid #d9d9d9;border-radius:4px;cursor:pointer;background:#fff' + (page <= 1 ? ';opacity:0.4;cursor:default' : '') + '"' + (page <= 1 ? ' disabled' : '') + '>‹</button>';
for (var p = 1; p <= totalPages; p++) {
var active = p === page;
recentHtml += '<button class="kb-page-btn" onclick="kbRecentGoPage(' + p + ')" style="font-size:11px;padding:2px 8px;border:1px solid ' + (active ? '#1890ff' : '#d9d9d9') + ';border-radius:4px;cursor:pointer;background:' + (active ? '#e6f7ff' : '#fff') + ';color:' + (active ? '#1890ff' : '#333') + ';font-weight:' + (active ? '600' : '400') + '">' + p + '</button>';
}
recentHtml += '<button class="kb-page-btn" onclick="kbRecentGoPage(' + (page + 1) + ')" style="font-size:11px;padding:2px 8px;border:1px solid #d9d9d9;border-radius:4px;cursor:pointer;background:#fff' + (page >= totalPages ? ';opacity:0.4;cursor:default' : '') + '"' + (page >= totalPages ? ' disabled' : '') + '>›</button>';
recentHtml += '<button class="kb-page-btn" onclick="kbRecentGoPage(' + totalPages + ')" style="font-size:11px;padding:2px 8px;border:1px solid #d9d9d9;border-radius:4px;cursor:pointer;background:#fff' + (page >= totalPages ? ';opacity:0.4;cursor:default' : '') + '"' + (page >= totalPages ? ' disabled' : '') + '>末页</button>';
recentHtml += '</div>';
}
recentHtml += '</div></div>';
}
var topics = idxRes.data.topics || [];
var html = recentHtml + '<div class="kb-topics">';
var hasAny = false;
topics.forEach(function(topic) {
var hasSubtopics = topic.subtopics && topic.subtopics.length > 0;
var totalCount = topic.articles.length;
if (hasSubtopics) {
topic.subtopics.forEach(function(sub) {
totalCount += sub.articles.length;
});
}
html += '<div class="kb-topic-card">';
html += '<div class="kb-topic-header"><span>📂</span> ' + escHtml(topic.name) + ' <span style="font-size:12px;color:var(--text-light,#999);font-weight:400">(' + totalCount + '篇)</span></div>';
html += '<div class="kb-topic-body">';
// 渲染子主题(作为可点击的文件夹,点击展开)
if (hasSubtopics) {
topic.subtopics.forEach(function(sub) {
if (sub.articles.length > 0) {
html += '<a class="kb-article-item" href="javascript:void(0)" onclick="kbOpenSubTopic(\'' + escJs(topic.name) + '\', \'' + escJs(sub.name) + '\')" style="font-weight:500">';
html += '<div><span>📁</span> ' + escHtml(sub.name) + '</div>';
html += '<span style="font-size:12px;color:var(--text-light,#999)">' + sub.articles.length + '篇 ›</span>';
html += '</a>';
hasAny = true;
}
});
}
// 渲染直属文章(不在子主题内的)
topic.articles.forEach(function(art) {
hasAny = true;
html += '<a class="kb-article-item" href="javascript:void(0)" onclick="kbOpenArticle(\'' + escJs(art.path) + '\')">';
html += '<div><span class="kb-article-title">📄 ' + escHtml(art.title) + '</span>';
if (art.summary && art.summary !== '—') {
html += '<span class="kb-article-summary">' + escHtml(art.summary) + '</span>';
}
html += '</div>';
html += '<span class="kb-article-date">' + (art.updated !== '—' ? art.updated : '') + '</span>';
html += '</a>';
});
if (!hasSubtopics && topic.articles.length === 0) {
html += '<div class="kb-empty">暂无文章</div>';
}
html += '</div></div>';
});
html += '</div>';
if (!hasAny) {
html = recentHtml + '<div style="text-align:center;padding:60px;color:var(--text-light,#999)"><div style="font-size:48px;margin-bottom:12px">📭</div><div style="font-size:15px">知识库暂无内容</div><div style="font-size:13px;margin-top:6px">让 OpenClaw 摄入资料后,文章会自动出现在这里</div></div>';
}
// 缓存索引数据用于子主题展开
window._kbIndexData = idxRes.data;
el.innerHTML = html;
})
.catch(function() {
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--danger)">❌ 加载失败,请检查网络连接</div>';
});
}
// 打开子主题(如训练记录)
window.kbOpenSubTopic = function(topicName, subTopicName, page) {
if (!page) page = 1;
var idxData = window._kbIndexData;
if (!idxData) {
kbLoadIndex();
return;
}
var topic = null;
(idxData.topics || []).forEach(function(t) {
if (t.name === topicName) topic = t;
});
if (!topic) return;
var sub = null;
(topic.subtopics || []).forEach(function(s) {
if (s.name === subTopicName) sub = s;
});
if (!sub || !sub.articles || sub.articles.length === 0) return;
// 训练记录:最新在前 + 每页10条
var isTraining = subTopicName === '训练记录';
var articles = sub.articles;
if (isTraining) {
// 按日期倒序排列(最新在前)
articles = articles.slice().sort(function(a, b) {
var da = a.updated || '0';
var db = b.updated || '0';
return db.localeCompare(da);
});
}
var pageSize = isTraining ? 18 : 999;
var totalPages = Math.ceil(articles.length / pageSize);
if (page > totalPages) page = totalPages;
var startIdx = (page - 1) * pageSize;
var pageItems = articles.slice(startIdx, startIdx + pageSize);
var el = document.getElementById('kbContent');
var html = '<div style="padding-bottom:12px">';
html += '<a class="kb-article-item" href="javascript:void(0)" onclick="kbLoadIndex()" style="display:inline-flex;gap:4px;color:var(--primary);font-weight:500">← 返回 ' + escHtml(topicName) + '</a>';
html += '<h3 style="margin:8px 0 0;font-size:16px;display:flex;align-items:center;gap:6px"><span>📁</span> ' + escHtml(subTopicName) + ' <span style="font-size:13px;color:var(--text-light,#999);font-weight:400">(' + articles.length + '篇)</span></h3>';
html += '</div>';
html += '<div style="border:1px solid var(--border,#e8e8e8);border-radius:6px;overflow:hidden">';
pageItems.forEach(function(art) {
html += '<a class="kb-article-item" href="javascript:void(0)" onclick="kbOpenArticle(\'' + escJs(art.path) + '\')">';
html += '<div><span class="kb-article-title">📄 ' + escHtml(art.title) + '</span>';
if (art.summary && art.summary !== '—') {
html += '<span class="kb-article-summary">' + escHtml(art.summary) + '</span>';
}
html += '</div>';
html += '<span class="kb-article-date">' + (art.updated !== '—' ? art.updated : '') + '</span>';
html += '</a>';
});
html += '</div>';
// 分页
if (isTraining && totalPages > 1) {
html += '<div style="display:flex;justify-content:center;align-items:center;gap:6px;padding:14px 0 4px">';
if (page > 1) html += '<button class="btn btn-sm" onclick="kbOpenSubTopic(\'' + escJs(topicName) + '\', \'' + escJs(subTopicName) + '\', ' + (page - 1) + ')">上一页</button>';
html += '<span style="font-size:13px;color:var(--text-light,#999)">第 ' + page + '/' + totalPages + ' 页</span>';
if (page < totalPages) html += '<button class="btn btn-sm" onclick="kbOpenSubTopic(\'' + escJs(topicName) + '\', \'' + escJs(subTopicName) + '\', ' + (page + 1) + ')">下一页</button>';
html += '</div>';
}
el.innerHTML = html;
};
function kbOpenArticle(path) {
var listCard = document.getElementById('kbMainCard');
listCard.style.display = 'none';
var articleCard = document.getElementById('kbArticleCard');
articleCard.style.display = '';
var contentDiv = document.getElementById('kbArticleContent');
contentDiv.innerHTML = '<div style="text-align:center;padding:60px 20px;color:var(--text-light,#999)"><div style="font-size:48px;margin-bottom:16px">⏳</div><div>加载文章...</div></div>';
document.getElementById('kbArticleTitle').textContent = decodeURIComponent(path);
fetch('/api/knowledge/article?path=' + encodeURIComponent(path))
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) {
contentDiv.innerHTML = '<div style="padding:40px;color:var(--danger)">❌ ' + (res.msg || '加载失败') + '</div>';
return;
}
var data = res.data;
document.getElementById('kbArticleTitle').textContent = data.path;
document.getElementById('kbArticleMeta').textContent = '更新于 ' + data.updated;
if (typeof marked !== 'undefined') {
contentDiv.innerHTML = '<div class="kb-article-view">' + marked.parse(data.content) + '</div>';
} else {
contentDiv.innerHTML = '<div class="kb-article-view"><pre>' + escHtml(data.content) + '</pre></div>';
}
// 外链在新窗口打开
contentDiv.querySelectorAll('a').forEach(function(a) {
if (a.getAttribute('href') && !a.getAttribute('href').startsWith('#')) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener');
}
});
})
.catch(function() {
contentDiv.innerHTML = '<div style="padding:40px;color:var(--danger)">❌ 加载失败</div>';
});
}
// 加载资料库
function kbLoadDatalib() {
var el = document.getElementById('kbPaneDatalib');
fetch('/api/knowledge/datalib')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0 || !res.data) {
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-light,#999)">暂无数据</div>';
return;
}
var data = res.data;
var html = '';
var sections = [
{key:'我的注视', icon:'👁️'},
{key:'我的技巧', icon:'🧰'},
{key:'我的目标', icon:'🎯'},
{key:'我的偏好', icon:'⚙️'},
{key:'我的疑问', icon:'❓'}
];
sections.forEach(function(s) {
var content = data[s.key] || '';
html += '<div class="kb-profile-block">';
html += '<div class="kb-profile-hd">' + s.icon + ' ' + escHtml(s.key) + '</div>';
html += '<div class="kb-profile-bd">';
if (typeof marked !== 'undefined') {
html += marked.parse(content);
} else {
html += '<pre>' + escHtml(content) + '</pre>';
}
html += '</div></div>';
});
el.innerHTML = html;
})
.catch(function() {
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--danger)">❌ 加载失败</div>';
});
}
// 加载画像
function kbLoadProfile() {
var el = document.getElementById('kbPaneProfile');
fetch('/api/knowledge/profile')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) {
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--danger)">❌ 加载失败</div>';
return;
}
var data = res.data || {};
var html = '';
// 画像概览
if (data.atoms) {
var atoms = data.atoms;
html += '<div class="kb-profile-block">';
html += '<div class="kb-profile-hd">🧑 个人画像概览</div>';
html += '<div class="kb-profile-bd">';
html += '<table><tr><th style="width:100px">当前关注</th><td>' + escHtml(atoms.注视 || '-') + '</td></tr>';
html += '<tr><th>技能</th><td>' + (Array.isArray(atoms.技能) ? atoms.技能.join('、') : '-') + '</td></tr>';
html += '<tr><th>偏好</th><td>' + (atoms.偏好 ? '语言:' + (atoms.偏好.语言||'中文') : '-') + '</td></tr>';
var goals = atoms.目标 || [];
html += '<tr><th>目标</th><td>' + (goals.length > 0 ? goals.map(function(g) { return g.名称 + '(' + g.状态 + ')'; }).join(';') : '-') + '</td></tr>';
html += '</table></div></div>';
}
// 画像 Markdown
if (data.profile) {
html += '<div class="kb-profile-block"><div class="kb-profile-hd">📝 画像详情</div>';
html += '<div class="kb-profile-bd">';
if (typeof marked !== 'undefined') {
html += marked.parse(data.profile);
} else {
html += '<pre>' + escHtml(data.profile.substring(0, 500)) + '</pre>';
}
html += '</div></div>';
}
el.innerHTML = html || '<div style="padding:40px;text-align:center;color:var(--text-light,#999)">暂无画像数据</div>';
})
.catch(function() {
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--danger)">❌ 加载失败</div>';
});
}
function kbBackToList() {
document.getElementById('kbArticleCard').style.display = 'none';
document.getElementById('kbMainCard').style.display = '';
}
function kbToggleRecent() {
var body = document.getElementById('kbRecentBody');
var toggle = document.getElementById('kbRecentToggle');
if (!body || !toggle) return;
var isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
toggle.textContent = isHidden ? '▼ 收起' : '▶ 展开';
}
function kbToggleDateActions(dateId) {
var body = document.getElementById(dateId);
var arrow = document.getElementById(dateId + '_arrow');
if (!body || !arrow) return;
var isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
arrow.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)';
}
function kbRecentGoPage(page) {
window._kbRecentPage = page;
kbLoadIndex();
}
function kbSearch() {
var q = document.getElementById('kbSearchInput').value.trim();
if (!q) { kbSwitchTab('wiki'); kbLoadIndex(); return; }
kbSwitchTab('wiki');
var el = document.getElementById('kbContent');
el.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-light,#999)">🔍 搜索中...</div>';
fetch('/api/knowledge/search?q=' + encodeURIComponent(q))
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0 || res.count === 0) {
el.innerHTML = '<div style="text-align:center;padding:60px;color:var(--text-light,#999)"><div style="font-size:48px;margin-bottom:12px">🔍</div><div style="font-size:15px">未找到相关结果</div><div style="font-size:13px;margin-top:6px">试试其他关键词</div><div style="margin-top:16px"><a href="javascript:void(0)" class="btn btn-sm" onclick="kbLoadIndex()">← 返回列表</a></div></div>';
return;
}
var html = '<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px"><span style="color:var(--text-light,#999);font-size:13px">找到 <strong>' + res.count + '</strong> 篇相关文章</span><a href="javascript:void(0)" class="btn btn-sm" onclick="kbLoadIndex()">← 返回列表</a></div>';
res.data.forEach(function(item) {
html += '<a href="javascript:void(0)" onclick="kbOpenArticle(\'' + escJs(item.path) + '\')" class="kb-article-item" style="margin:0 12px 4px 12px">';
html += '<div><span class="kb-article-title">📄 ' + escHtml(item.title) + '</span><span class="kb-article-summary">' + escHtml(item.path) + '</span></div>';
html += '</a>';
});
el.innerHTML = html;
});
}
// ─── 计划预测 - 冻结列配置 ───
var planFrozen = true;
var planFrozenFields = ['product_type', 'product_code', 'product_name'];
var FROZEN_WIDTHS = {
'product_type': 75,
'product_code': 130,
'product_name': 210
};
function loadPage(page) {
const mainContent = document.getElementById('mainContent');
// 更新导航高亮
document.querySelectorAll('.nav-item, .nav-sub-item').forEach(el => el.classList.remove('active'));
document.querySelector(`.nav-item[data-page="${page}"]`)?.classList.add('active');
document.querySelector(`.nav-sub-item[data-page="${page}"]`)?.classList.add('active');
// 自动展开子菜单
document.querySelectorAll('.nav-submenu').forEach(m => m.classList.remove('open'));
document.querySelectorAll('.nav-arrow').forEach(a => a.classList.remove('rotated'));
// 更新库存页面类型变量(成品/包材)
if (page === 'inventory_finished' || page === 'inventory_packaging') {
_invProductType = page === 'inventory_finished' ? '成品' : '包材';
}
var activeSub = document.querySelector(`.nav-sub-item[data-page="${page}"]`);
if (activeSub) {
var submenu = activeSub.closest('.nav-submenu');
if (submenu) {
submenu.classList.add('open');
var arrow = submenu.closest('.nav-group')?.querySelector('.nav-arrow');
if (arrow) arrow.classList.add('rotated');
}
}
// 从 DOM 中移除所有已缓存的页面容器(保持内存缓存),
// 避免跨页面重复 ID 导致 getElementById 定位到错误的元素
var cachedContainers = mainContent.querySelectorAll('.page-container');
cachedContainers.forEach(function(el) {
el.remove();
});
// 如果已有缓存,重新附加到 DOM 并显示
if (window._pageCache[page]) {
var cached = window._pageCache[page];
mainContent.appendChild(cached);
cached.style.display = '';
// 产品基础表/供应商管理:每次切回时清除筛选框记忆并刷新
if (page === 'products') {
['prodCode','prodName','prodSupplier','prodTypeFilter'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
if (typeof loadProducts === 'function') loadProducts(1);
}
if (page === 'suppliers') {
['supCode','supName'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
if (typeof loadSuppliers === 'function') loadSuppliers(1);
}
// 先销毁原有 Chart 实例(canvas detach 后 chart 可能状态错乱)
if (typeof Chart !== 'undefined') {
const canvases = cached.querySelectorAll('canvas');
canvases.forEach(function(c) {
try {
var chart = Chart.getChart(c);
if (chart) { chart.destroy(); }
} catch(e) {}
});
}
// 等浏览器布局完成后重新绘制
if (window._pageDataCache && window._pageDataCache[page]) {
var reloader = window._pageReloaders[page];
if (reloader) {
requestAnimationFrame(function() {
setTimeout(function() {
reloader(window._pageDataCache[page]);
}, 50);
});
}
}
return;
}
// 首次加载:移除初始loading占位,创建容器
const loading = document.getElementById('pageLoading');
if (loading) loading.style.display = 'none';
const container = document.createElement('div');
container.className = 'page-container';
container.id = 'page-container-' + page;
mainContent.appendChild(container);
// 调用对应加载函数
if (PAGE_LOADERS[page]) {
PAGE_LOADERS[page](container);
}
window._pageCache[page] = container;
}
// ============ SPA 导航系统 ============
// 菜单页面路径 → page key 映射
var PAGE_MAP = {
'/': 'dashboard',
'/data/summary': 'data_summary',
'/data/detail': 'data_detail',
'/inventory/finished': 'inventory_finished',
'/inventory/packaging': 'inventory_packaging',
'/planning': 'planning',
'/products': 'products',
'/bom': 'bom',
'/warehouse': 'warehouse',
'/suppliers': 'suppliers',
'/purchase': 'purchase',
'/purchase/ready': 'purchase_ready',
'/orders/domestic': 'orders_domestic',
'/orders/overseas': 'orders_overseas',
'/orders/packaging': 'orders_packaging',
'/users': 'users',
'/login-records': 'login_records',
'/logs': 'logs',
'/config': 'config',
'/permissions': 'permissions',
'/knowledge': 'knowledge',
'/feedback': 'feedback',
'/contract-templates': 'contract_templates'
};
// 拦截导航点击,启用 SPA 缓存
function initNavigation() {
document.addEventListener('click', function(e) {
var link = e.target.closest('.nav-item, .nav-sub-item');
if (!link) return;
var page = link.getAttribute('data-page');
if (!page) return;
e.preventDefault();
var href = link.getAttribute('href') || '/';
history.pushState({page: page}, '', href);
loadPage(page);
});
// 浏览器后退/前进
window.addEventListener('popstate', function(e) {
var state = e.state;
if (state && state.page) {
loadPage(state.page);
} else {
// 首次加载或状态丢失,从当前 URL 推断
var path = window.location.pathname;
var p = PAGE_MAP[path];
if (p) loadPage(p);
}
});
}
// 延迟初始化,确保 DOM 加载完成后再绑定事件
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initNavigation);
} else {
initNavigation();
}
// ─── 工具函数 ───
function escJs(str) { return String(str).replace(/'/g, "\\'").replace(/\\/g, '\\\\').replace(/"/g, '&quot;'); }
function escHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function truncate(str, n) {
if (!str) return '';
return str.length > n ? str.substring(0, n) + '...' : str;
}
// 等待Chart.js加载完成
function waitChartJS() {
return new Promise(function(resolve) {
if (typeof Chart !== 'undefined') { resolve(); return; }
var check = setInterval(function() {
if (typeof Chart !== 'undefined') {
clearInterval(check);
resolve();
}
}, 100);
setTimeout(function() { clearInterval(check); resolve(); }, 10000);
});
}
// ─── 测试问题(意见反馈)───
function loadFeedbackPage(container) {
renderFeedbackPage(container);
}
function renderFeedbackPage(container) {
container.innerHTML = `
<style>
.fb-header { display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:10px; }
.fb-tabs { display:flex;gap:4px;background:var(--bg-card,#f7f8fa);padding:4px;border-radius:10px; }
.fb-tab { padding:8px 20px;font-size:13px;font-weight:600;cursor:pointer;color:var(--text-light,#888);border-radius:8px;transition:all .25s;user-select:none; }
.fb-tab:hover { color:var(--text,#333);background:rgba(0,0,0,0.04); }
.fb-tab.active { color:#1890ff;background:#e6f7ff;box-shadow:0 1px 4px rgba(24,144,255,0.12); }
.fb-grid { display:grid;grid-template-columns:1fr 1fr;gap:16px; }
@media (max-width:1100px) { .fb-grid { grid-template-columns:1fr; } }
.fb-list { background:var(--bg-card,#fff);border:1px solid var(--border,#e8e8e8);border-radius:8px;overflow:hidden; }
.fb-list-header { padding:12px 16px;background:#f9f9f9;border-bottom:1px solid var(--border,#e8e8e8);font-weight:600;font-size:14px; }
.fb-item { padding:14px 16px;border-bottom:1px solid var(--border,#f0f0f0);cursor:pointer;transition:background .12s; }
.fb-item:hover { background:#f5f7fa; }
.fb-item.selected { background:#e6f7ff;border-left:3px solid #1890ff; }
.fb-item-title { font-weight:600;font-size:14px;margin-bottom:4px; }
.fb-item-meta { display:flex;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--text-light,#999); }
.fb-badge { display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600; }
.fb-badge-pending { background:#fff7e6;color:#fa8c16; }
.fb-badge-resolved { background:#f6ffed;color:#52c41a; }
.fb-badge-urgent { background:#fff1f0;color:#f5222d; }
.fb-badge-high { background:#fff7e6;color:#fa8c16; }
.fb-badge-medium { background:#e6f7ff;color:#1890ff; }
.fb-badge-low { background:#f5f5f5;color:#999; }
.fb-detail { background:var(--bg-card,#fff);border:1px solid var(--border,#e8e8e8);border-radius:8px;overflow:hidden; }
.fb-detail-empty { padding:40px 20px;text-align:center;color:var(--text-light,#bbb);font-size:14px; }
.fb-detail-body { padding:20px; }
.fb-detail-title { font-size:18px;font-weight:700;margin-bottom:12px; }
.fb-detail-meta { display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px;font-size:13px;color:var(--text-light,#888); }
.fb-detail-desc { font-size:14px;line-height:1.8;color:#333;margin-bottom:16px;white-space:pre-wrap; }
.fb-attachments { display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px; }
.fb-attach-item { display:flex;align-items:center;gap:6px;padding:6px 12px;background:#f5f5f5;border-radius:6px;font-size:12px;cursor:pointer;text-decoration:none;color:#333; }
.fb-attach-item:hover { background:#e6f7ff; }
.fb-attach-img { width:100px;height:100px;object-fit:cover;border-radius:6px;cursor:pointer;border:1px solid #eee;transition:transform .15s; }
.fb-attach-img:hover { transform:scale(1.05);box-shadow:0 2px 8px rgba(0,0,0,0.12); }
.fb-preview-overlay { position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.75);z-index:20000;display:flex;align-items:center;justify-content:center;cursor:pointer; }
.fb-preview-overlay img { max-width:90vw;max-height:90vh;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,0.3); }
.fb-preview-close { position:fixed;top:20px;right:30px;font-size:28px;color:#fff;background:rgba(0,0,0,0.4);border:none;width:44px;height:44px;border-radius:50%;cursor:pointer;z-index:20001;display:flex;align-items:center;justify-content:center;transition:background .2s; }
.fb-preview-close:hover { background:rgba(0,0,0,0.7); }
.fb-form-overlay { position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:15000;display:flex;align-items:center;justify-content:center; }
.fb-form-box { background:#fff;border-radius:12px;padding:24px;width:540px;max-width:94vw;max-height:90vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,0.18); }
.fb-form-box h2 { margin:0 0 16px 0;font-size:18px; }
.fb-form-row { margin-bottom:14px; }
.fb-form-row label { display:block;font-size:13px;font-weight:600;margin-bottom:4px;color:#555; }
.fb-form-row input,.fb-form-row select,.fb-form-row textarea { width:100%;box-sizing:border-box;padding:8px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;outline:none;transition:border .2s; }
.fb-form-row input:focus,.fb-form-row select:focus,.fb-form-row textarea:focus { border-color:#1890ff;box-shadow:0 0 0 2px rgba(24,144,255,0.1); }
.fb-form-row textarea { min-height:100px;resize:vertical; }
.fb-form-actions { display:flex;justify-content:flex-end;gap:10px;margin-top:6px; }
.fb-file-list { display:flex;flex-wrap:wrap;gap:8px;margin-top:8px; }
.fb-file-tag { display:inline-flex;align-items:center;gap:4px;padding:4px 10px;background:#f5f5f5;border-radius:4px;font-size:12px; }
.fb-file-tag .remove { cursor:pointer;color:#999;font-size:14px; }
.fb-file-tag .remove:hover { color:#f5222d; }
.fb-pagination { display:flex;justify-content:center;gap:4px;padding:12px 0; }
.fb-page-btn { padding:4px 12px;border:1px solid #d9d9d9;border-radius:4px;font-size:12px;cursor:pointer;background:#fff; }
.fb-page-btn:hover { border-color:#1890ff;color:#1890ff; }
.fb-page-btn.active { background:#1890ff;color:#fff;border-color:#1890ff; }
.fb-page-btn:disabled { opacity:0.4;cursor:default; }
.fb-dropzone { border:2px dashed #d9d9d9;border-radius:8px;padding:20px;text-align:center;cursor:pointer;transition:all .2s; }
.fb-dropzone:hover,.fb-dropzone.dragover { border-color:#1890ff;background:#f0f8ff; }
</style>
<div class="page-header">
<h1>🐛 测试问题</h1>
<p class="page-desc">内部问题反馈与优化建议收集</p>
</div>
<div class="fb-header">
<div class="fb-tabs" id="fbTabs">
<div class="fb-tab active" data-status="" onclick="switchFbTab('')">全部</div>
<div class="fb-tab" data-status="pending" onclick="switchFbTab('pending')">待处理</div>
<div class="fb-tab" data-status="resolved" onclick="switchFbTab('resolved')">已完成</div>
</div>
<div style="display:flex;gap:8px;align-items:center">
<select id="fbCategory" class="form-input" style="width:auto;min-width:100px;height:32px;font-size:13px" onchange="window._fbCategory=this.value;window._fbPage=1;loadFbList()">
<option value="">全部分类</option>
<option value="功能问题">功能问题</option>
<option value="数据错误">数据错误</option>
<option value="界面优化">界面优化</option>
<option value="性能问题">性能问题</option>
<option value="新增需求">新增需求</option>
<option value="其他">其他</option>
</select>
<select id="fbSort" class="form-input" style="width:auto;min-width:100px;height:32px;font-size:13px" onchange="window._fbSort=this.value;window._fbPage=1;loadFbList()">
<option value="default">默认排序</option>
<option value="priority">按紧急程度</option>
</select>
<input type="text" id="fbSearch" class="form-input" style="width:200px" placeholder="🔍 搜索标题或描述" onkeydown="if(event.key==='Enter'){window._fbSearch=this.value;window._fbPage=1;loadFbList();}">
<button class="btn btn-outline" onclick="var s=document.getElementById('fbSearch');s.value='';window._fbSearch='';window._fbPage=1;loadFbList()">清除</button>
<button class="btn btn-primary" onclick="showFbForm()">+ 新增反馈</button>
</div>
</div>
<div class="fb-grid">
<div class="fb-list" id="fbList">
<div class="fb-list-header">反馈列表 (<span id="fbTotal">0</span>)</div>
<div id="fbItems" style="min-height:200px"></div>
<div class="fb-pagination" id="fbPagination"></div>
</div>
<div class="fb-detail" id="fbDetail">
<div class="fb-detail-empty">👈 点击左侧反馈查看详情</div>
</div>
</div>
`;
window._fbStatus = '';
window._fbPage = 1;
window._fbSort = 'default';
window._fbCategory = '';
window._fbSelected = null;
loadFbList();
}
// ─── 反馈列表加载 ───
function loadFbList() {
var status = window._fbStatus || '';
var page = window._fbPage || 1;
var search = (window._fbSearch || '').trim();
var sort = window._fbSort || 'default';
var category = window._fbCategory || '';
var url = '/api/feedback/list?page=' + page + '&size=10&status=' + status + '&sort=' + sort;
if (category) url += '&category=' + encodeURIComponent(category);
if (search) url += '&search=' + encodeURIComponent(search);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
var data = res.data;
document.getElementById('fbTotal').textContent = data.total;
var html = '';
if (data.rows.length === 0) {
html = '<div style="padding:40px;text-align:center;color:#bbb;font-size:14px">暂无反馈</div>';
} else {
data.rows.forEach(function(item) {
var statusClass = item.status === 'resolved' ? 'fb-badge-resolved' : 'fb-badge-pending';
var statusText = item.status === 'resolved' ? '已完成' : '待处理';
var priorityClass = item.priority === '紧急' ? 'fb-badge-urgent' : (item.priority === '高' ? 'fb-badge-high' : (item.priority === '低' ? 'fb-badge-low' : 'fb-badge-medium'));
var selected = window._fbSelected && window._fbSelected.id === item.id ? ' selected' : '';
html += '<div class="fb-item' + selected + '" onclick="selectFbItem(' + item.id + ')">';
html += '<div class="fb-item-title">' + escHtml(item.title) + '</div>';
html += '<div class="fb-item-meta">';
html += '<span class="fb-badge ' + statusClass + '">' + statusText + '</span>';
html += '<span class="fb-badge ' + priorityClass + '">' + escHtml(item.priority) + '</span>';
html += '<span>' + escHtml(item.category) + '</span>';
html += '<span>' + escHtml(item.user_name) + '</span>';
html += '<span>' + (item.created_at || '') + '</span>';
html += '</div></div>';
});
}
document.getElementById('fbItems').innerHTML = html;
// 分页
var totalPages = Math.ceil(data.total / data.size);
var pgHtml = '';
if (totalPages > 1) {
pgHtml += '<button class="fb-page-btn" onclick="fbGoPage(1)" ' + (page <= 1 ? 'disabled' : '') + '>首页</button>';
pgHtml += '<button class="fb-page-btn" onclick="fbGoPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + '>‹</button>';
var start = Math.max(1, page - 2);
var end = Math.min(totalPages, page + 2);
for (var i = start; i <= end; i++) {
pgHtml += '<button class="fb-page-btn' + (i === page ? ' active' : '') + '" onclick="fbGoPage(' + i + ')">' + i + '</button>';
}
pgHtml += '<button class="fb-page-btn" onclick="fbGoPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + '>›</button>';
pgHtml += '<button class="fb-page-btn" onclick="fbGoPage(' + totalPages + ')" ' + (page >= totalPages ? 'disabled' : '') + '>末页</button>';
}
document.getElementById('fbPagination').innerHTML = pgHtml;
// 选中状态恢复
if (window._fbSelected) {
var found = data.rows.some(function(r) { return r.id === window._fbSelected.id; });
if (!found) window._fbSelected = null;
showFbDetail();
}
});
}
function fbGoPage(p) {
window._fbPage = p;
loadFbList();
}
function switchFbTab(status) {
window._fbStatus = status;
window._fbPage = 1;
window._fbSort = 'default';
window._fbCategory = '';
window._fbSelected = null;
document.getElementById('fbSort').value = 'default';
document.getElementById('fbCategory').value = '';
document.getElementById('fbSearch').value = '';
window._fbSearch = '';
document.querySelectorAll('#fbTabs .fb-tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelector('#fbTabs .fb-tab[data-status="' + status + '"]').classList.add('active');
document.getElementById('fbDetail').innerHTML = '<div class="fb-detail-empty">👈 点击左侧反馈查看详情</div>';
loadFbList();
}
// ─── 选中反馈 ───
function selectFbItem(id) {
fetch('/api/feedback/detail?id=' + id)
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0 || !res.data) return;
window._fbSelected = res.data;
document.querySelectorAll('.fb-item').forEach(function(el) { el.classList.remove('selected'); });
var items = document.querySelectorAll('.fb-item');
for (var i = 0; i < items.length; i++) {
if (items[i].querySelector('.fb-item-title').textContent === window._fbSelected.title) {
items[i].classList.add('selected');
break;
}
}
showFbDetail();
});
}
function showFbDetail() {
var item = window._fbSelected;
if (!item) {
document.getElementById('fbDetail').innerHTML = '<div class="fb-detail-empty">👈 点击左侧反馈查看详情</div>';
return;
}
var statusClass = item.status === 'resolved' ? 'fb-badge-resolved' : 'fb-badge-pending';
var statusText = item.status === 'resolved' ? '✅ 已完成' : '⏳ 待处理';
var priorityClass = item.priority === '紧急' ? 'fb-badge-urgent' : (item.priority === '高' ? 'fb-badge-high' : (item.priority === '低' ? 'fb-badge-low' : 'fb-badge-medium'));
var attachHtml = '';
if (item.attachments && item.attachments.length > 0) {
attachHtml = '<div class="fb-attachments">';
item.attachments.forEach(function(a) {
var isImage = ['png','jpg','jpeg','gif','webp','bmp','svg'].indexOf(a.type) >= 0;
if (isImage) {
var imgSrc = a.path.startsWith('/') ? '/static' + a.path : '/static/' + a.path;
attachHtml += '<img class="fb-attach-img" src="' + imgSrc + '" title="' + escHtml(a.name) + '" onclick="previewFbImage(\'' + imgSrc + '\')">';
} else {
var sizeStr = a.size > 1024*1024 ? (a.size/1024/1024).toFixed(1)+'MB' : (a.size/1024).toFixed(1)+'KB';
attachHtml += '<a class="fb-attach-item" href="' + a.path + '" target="_blank">📎 ' + escHtml(a.name) + ' (' + sizeStr + ')</a>';
}
});
attachHtml += '</div>';
}
var actionsHtml = '';
var isAdmin = window._currentUser && (window._currentUser.role === 'admin' || window._currentUser.role === 'super_admin');
var isOwner = window._currentUser && window._currentUser.user_id === item.user_id;
var canEdit = isAdmin || isOwner;
if (isAdmin) {
if (item.status === 'pending') {
actionsHtml += '<button class="btn btn-success" onclick="resolveFbItem(' + item.id + ')">✅ 标记完成</button>';
} else {
actionsHtml += '<button class="btn btn-outline" onclick="unresolveFbItem(' + item.id + ')">🔄 重新打开</button>';
}
}
if (canEdit) {
actionsHtml += '<button class="btn btn-outline" onclick="editFbItem(' + item.id + ')">✏️ 编辑</button>';
actionsHtml += '<button class="btn btn-outline" style="color:#f5222d;border-color:#f5222d" onclick="deleteFbItem(' + item.id + ')">🗑️ 删除</button>';
}
var resolvedInfo = '';
if (item.status === 'resolved' && item.resolved_at) {
resolvedInfo = '<div style="margin-top:12px;padding:10px 14px;background:#f6ffed;border-radius:6px;font-size:13px;color:#52c41a">✅ 已于 ' + escHtml(item.resolved_at) + ' 修改完成</div>';
}
document.getElementById('fbDetail').innerHTML = '<div class="fb-detail-body"><div class="fb-detail-title">' + escHtml(item.title) + '</div><div class="fb-detail-meta"><span class="fb-badge ' + statusClass + '">' + statusText + '</span><span class="fb-badge ' + priorityClass + '">' + escHtml(item.priority) + '</span><span>' + escHtml(item.category) + '</span><span>' + escHtml(item.user_name) + '</span><span>' + (item.created_at || '') + '</span></div><div class="fb-detail-desc">' + escHtml(item.description || '暂无详细描述') + '</div>' + attachHtml + '<div style="display:flex;gap:10px">' + actionsHtml + '</div>' + resolvedInfo + '</div>';
}
function previewFbImage(src) {
var imgSrc = src.startsWith('/') && !src.startsWith('/static') ? '/static' + src : src;
var overlay = document.createElement('div');
overlay.className = 'fb-preview-overlay';
overlay.innerHTML = '<img src="' + imgSrc + '"><button class="fb-preview-close" onclick="this.parentElement.remove()">✕</button>';
overlay.onclick = function(e) { if (e.target === overlay) {} };
document.body.appendChild(overlay);
}
// ─── 新增/编辑反馈表单 ───
function showFbForm(item) {
var isEdit = item && item.id;
var overlay = document.createElement('div');
overlay.className = 'fb-form-overlay';
overlay.id = 'fbFormOverlay';
var titleText = isEdit ? '✏️ 编辑反馈' : '📝 提交反馈';
var btnText = isEdit ? '保存修改' : '提交反馈';
overlay.innerHTML = '<div class="fb-form-box" onclick="event.stopPropagation()"><span id="fbEditId" style="display:none">' + (item ? item.id : '') + '</span><h2>' + titleText + '</h2><div class="fb-form-row"><label>标题 *</label><input id="fbFormTitle" placeholder="请用一句话概括问题或建议" maxlength="200" value="' + escHtml(item ? item.title : '') + '"></div><div style="display:flex;gap:12px"><div class="fb-form-row" style="flex:1"><label>分类</label><select id="fbFormCategory"><option value="功能问题">功能问题</option><option value="数据错误">数据错误</option><option value="界面优化">界面优化</option><option value="性能问题">性能问题</option><option value="新增需求">新增需求</option><option value="其他">其他</option></select></div><div class="fb-form-row" style="flex:1"><label>紧急程度</label><select id="fbFormPriority"><option value="紧急">紧急</option><option value="高">高</option><option value="中">中</option><option value="低">低</option></select></div></div><div class="fb-form-row"><label>详细描述</label><textarea id="fbFormDesc" placeholder="请详细描述问题现象或建议内容...">' + escHtml(item ? item.description : '') + '</textarea></div><div class="fb-form-row"><label>附件(支持图片、文档、压缩包等,编辑时重新上传会追加)</label><div class="fb-dropzone" id="fbDropzone" onclick="document.getElementById(&#39;fbFileInput&#39;).click()" ondrop="handleFbDrop(event)" ondragover="event.preventDefault();this.classList.add(&#39;dragover&#39;)" ondragleave="this.classList.remove(&#39;dragover&#39;)">📎 点击选择文件,或将文件拖拽到此处</div><input type="file" id="fbFileInput" multiple style="display:none" onchange="handleFbFileSelect(event)"><div class="fb-file-list" id="fbFileList"></div></div><div class="fb-form-actions"><button class="btn btn-outline" onclick="closeFbForm()">取消</button><button class="btn btn-primary" onclick="submitFbForm()">' + btnText + '</button></div></div>';
overlay.onclick = function(e) { if (e.target === overlay) {} };
document.body.appendChild(overlay);
window._fbFiles = [];
// 编辑模式:预填分类和紧急度
if (isEdit) {
var catSelect = document.getElementById('fbFormCategory');
var priSelect = document.getElementById('fbFormPriority');
for (var i = 0; i < catSelect.options.length; i++) {
if (catSelect.options[i].value === item.category) {
catSelect.selectedIndex = i; break;
}
}
for (var i = 0; i < priSelect.options.length; i++) {
if (priSelect.options[i].value === item.priority) {
priSelect.selectedIndex = i; break;
}
}
// 显示已有附件
if (item.attachments && item.attachments.length > 0) {
var existingHtml = '';
item.attachments.forEach(function(a) {
existingHtml += '<div class="fb-file-tag">📎 ' + escHtml(a.name) + '</div>';
});
document.getElementById('fbFileList').innerHTML = '<div style="font-size:12px;color:#999;margin-bottom:4px">已有附件:</div>' + existingHtml;
}
}
}
function closeFbForm() {
var overlay = document.getElementById('fbFormOverlay');
if (overlay) overlay.remove();
window._fbFiles = null;
}
var FB_FILE_ID = 0;
function handleFbFileSelect(e) {
var files = e.target.files;
for (var i = 0; i < files.length; i++) {
addFbFile(files[i]);
}
e.target.value = '';
}
function handleFbDrop(e) {
e.preventDefault();
document.getElementById('fbDropzone').classList.remove('dragover');
var files = e.dataTransfer.files;
for (var i = 0; i < files.length; i++) {
addFbFile(files[i]);
}
}
function addFbFile(file) {
if (!window._fbFiles) window._fbFiles = [];
window._fbFiles.push(file);
renderFbFileList();
}
function removeFbFile(idx) {
window._fbFiles.splice(idx, 1);
renderFbFileList();
}
function renderFbFileList() {
var html = '';
if (window._fbFiles) {
for (var i = 0; i < window._fbFiles.length; i++) {
var f = window._fbFiles[i];
var isImage = f.type && f.type.startsWith('image/');
var preview = isImage ? '<img src="' + URL.createObjectURL(f) + '" style="width:32px;height:32px;object-fit:cover;border-radius:4px">' : '📄';
html += '<div class="fb-file-tag">' + preview + ' ' + escHtml(f.name) + ' <span class="remove" onclick="removeFbFile(' + i + ')">✕</span></div>';
}
}
document.getElementById('fbFileList').innerHTML = html;
}
function submitFbForm() {
var title = document.getElementById('fbFormTitle').value.trim();
if (!title) { alert('请输入标题'); return; }
var desc = document.getElementById('fbFormDesc').value.trim();
var category = document.getElementById('fbFormCategory').value;
var priority = document.getElementById('fbFormPriority').value;
var editId = document.getElementById('fbEditId') ? document.getElementById('fbEditId').textContent.trim() : '';
var formData = new FormData();
formData.append('title', title);
formData.append('description', desc);
formData.append('category', category);
formData.append('priority', priority);
if (editId) {
formData.append('id', editId);
}
if (window._fbFiles) {
for (var i = 0; i < window._fbFiles.length; i++) {
formData.append('files', window._fbFiles[i]);
}
}
var btn = document.querySelector('#fbFormOverlay .btn-primary');
btn.disabled = true;
btn.textContent = '提交中...';
var url = editId ? '/api/feedback/update' : '/api/feedback/submit';
fetch(url, { method: 'POST', body: formData })
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
closeFbForm();
window._fbSelected = null;
loadFbList();
} else {
alert(res.msg || '提交失败');
btn.disabled = false;
btn.textContent = editId ? '保存修改' : '提交反馈';
}
})
.catch(function() {
alert('提交失败,请重试');
btn.disabled = false;
btn.textContent = editId ? '保存修改' : '提交反馈';
});
}
// ─── 标记完成/重新打开 ───
function resolveFbItem(id) {
if (!confirm('确认将此反馈标记为已完成?')) return;
fetch('/api/feedback/resolve', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
loadFbList();
} else {
alert(res.msg || '操作失败');
}
});
}
function unresolveFbItem(id) {
if (!confirm('确认重新打开此反馈?')) return;
fetch('/api/feedback/unresolve', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
loadFbList();
} else {
alert(res.msg || '操作失败');
}
});
}
// ─── 删除反馈 ───
function deleteFbItem(id) {
if (!confirm('确认删除此反馈?删除后不可恢复。')) return;
fetch('/api/feedback/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
window._fbSelected = null;
document.getElementById('fbDetail').innerHTML = '<div class="fb-detail-empty">👈 点击左侧反馈查看详情</div>';
loadFbList();
} else {
alert(res.msg || '操作失败');
}
});
}
// ─── 编辑反馈 ───
function editFbItem(id) {
fetch('/api/feedback/detail?id=' + id)
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0 || !res.data) return;
var item = res.data;
showFbForm(item);
});
}
// ─── 合同模板 ───
function loadContractTemplatesPage(container) {
container.innerHTML = `
<style>
.ct-header { display:flex;justify-content:space-between;align-items:center;margin-bottom:16px; }
.ct-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px; }
.ct-card { background:var(--bg-card,#fff);border:1px solid var(--border,#e8e8e8);border-radius:8px;overflow:hidden; }
.ct-card-body { padding:16px; }
.ct-card-title { font-weight:600;font-size:15px;margin-bottom:6px; }
.ct-card-meta { font-size:12px;color:var(--text-light,#999);margin-bottom:10px; }
.ct-card-actions { display:flex;gap:8px; }
.ct-empty { padding:40px;text-align:center;color:var(--text-light,#bbb);font-size:14px; }
.ct-form-overlay { position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:15000;display:flex;align-items:center;justify-content:center; }
.ct-form-box { background:#fff;border-radius:12px;padding:24px;width:500px;max-width:94vw;box-shadow:0 8px 32px rgba(0,0,0,0.18); }
.ct-form-box h2 { margin:0 0 16px 0;font-size:18px; }
.ct-form-row { margin-bottom:14px; }
.ct-form-row label { display:block;font-size:13px;font-weight:600;margin-bottom:4px;color:#555; }
.ct-form-row input,.ct-form-row select { width:100%;box-sizing:border-box;padding:8px 12px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;outline:none; }
.ct-form-actions { display:flex;justify-content:flex-end;gap:10px;margin-top:6px; }
</style>
<div class="page-header">
<h1>📄 合同模板</h1>
<p class="page-desc">上传和管理采购合同模板</p>
</div>
<div class="ct-header">
<div></div>
<button class="btn btn-primary" onclick="showCtUploadForm()">+ 上传模板</button>
</div>
<div id="ctList" class="ct-grid"></div>
`;
loadCtTemplates();
}
function loadCtTemplates() {
fetch('/api/contract/templates')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
var list = document.getElementById('ctList');
if (!res.data || res.data.length === 0) {
list.innerHTML = '<div class="ct-empty">暂无模板,请点击右上角上传</div>';
return;
}
var html = '';
res.data.forEach(function(t) {
var typeMap = {'domestic':'国内订单','overseas':'海外订单','packaging':'包材订单','all':'全部类型'};
html += '<div class="ct-card"><div class="ct-card-body">';
html += '<div class="ct-card-title">' + escHtml(t.name) + '</div>';
html += '<div class="ct-card-meta">' + (typeMap[t.order_type] || t.order_type) + ' | 文件: ' + escHtml(t.file_name || '') + ' | ' + (t.created_at || '') + '</div>';
html += '<div class="ct-card-actions"><button class="btn btn-sm btn-outline" style="color:#f5222d;border-color:#f5222d" onclick="deleteCtTemplate(' + t.id + ')">删除</button></div>';
html += '</div></div>';
});
list.innerHTML = html;
});
}
function showCtUploadForm() {
var overlay = document.createElement('div');
overlay.className = 'ct-form-overlay';
overlay.id = 'ctFormOverlay';
overlay.innerHTML = '<div class="ct-form-box" onclick="event.stopPropagation()"><h2>📤 上传合同模板</h2><div class="ct-form-row"><label>模板名称</label><input id="ctFormName" placeholder="如:标准采购合同"></div><div class="ct-form-row"><label>适用类型</label><select id="ctFormType"><option value="all">全部类型</option><option value="domestic">国内订单</option><option value="overseas">海外订单</option><option value="packaging">包材订单</option></select></div><div class="ct-form-row"><label>模板文件(.xlsx)</label><input type="file" id="ctFormFile" accept=".xlsx"></div><div class="ct-form-actions"><button class="btn btn-outline" onclick="closeCtForm()">取消</button><button class="btn btn-primary" onclick="submitCtUpload()">上传</button></div></div>';
overlay.onclick = function(e) { if (e.target === overlay) {} };
document.body.appendChild(overlay);
}
function closeCtForm() {
var el = document.getElementById('ctFormOverlay');
if (el) el.remove();
}
function submitCtUpload() {
var name = document.getElementById('ctFormName').value.trim();
var type = document.getElementById('ctFormType').value;
var fileInput = document.getElementById('ctFormFile');
if (!name) { alert('请输入模板名称'); return; }
if (!fileInput.files || !fileInput.files[0]) { alert('请选择文件'); return; }
var formData = new FormData();
formData.append('name', name);
formData.append('order_type', type);
formData.append('file', fileInput.files[0]);
var btn = document.querySelector('#ctFormOverlay .btn-primary');
btn.disabled = true; btn.textContent = '上传中...';
fetch('/api/contract/template/upload', { method: 'POST', body: formData })
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) { closeCtForm(); loadCtTemplates(); }
else { alert(res.msg || '上传失败'); btn.disabled = false; btn.textContent = '上传'; }
});
}
function deleteCtTemplate(id) {
if (!confirm('确认删除此模板?')) return;
fetch('/api/contract/template/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: id}) })
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) loadCtTemplates();
else alert(res.msg || '操作失败');
});
}
// ─── 合同生成功能 ───
var _contractGenDialogOpen = false;
var _contractGenerating = false;
function generateContractForOrders(orderType, orderIds) {
// 读取模板列表
fetch('/api/contract/templates')
.then(function(r) {
if (!r.ok) throw new Error('请求失败: ' + r.status);
return r.json();
})
.then(function(res) {
if (res.code !== 0 || !res.data || res.data.length === 0) {
alert('请先在 系统设置→合同模板 中上传合同模板');
return;
}
var templates = res.data;
// 弹窗选择模板
if (_contractGenDialogOpen) return;
_contractGenDialogOpen = true;
var overlay = document.createElement('div');
overlay.className = 'ct-form-overlay';
overlay.id = 'contractGenOverlay';
var typeMap = {'domestic':'国内订单','overseas':'海外订单','packaging':'包材订单','all':'全部类型'};
var templateOptions = '';
templates.forEach(function(t) {
templateOptions += '<option value="' + t.id + '">' + escHtml(t.name) + ' (' + (typeMap[t.order_type] || t.order_type) + ')</option>';
});
overlay.innerHTML = '<div class="ct-form-box" onclick="event.stopPropagation()"><h2>📄 生成合同</h2><div class="ct-form-row"><label>订单类型</label><input class="form-input" value="' + typeMap[orderType] + '" disabled></div><div class="ct-form-row"><label>选择模板</label><select id="contractGenTemplate">' + templateOptions + '</select></div><div class="ct-form-row"><label>已选订单数</label><input class="form-input" value="' + orderIds.length + ' 条" disabled></div><div class="ct-form-row"><label style="font-size:12px;color:#888">📌 同一供货商的订单会自动合并为一份合同</label></div><div class="ct-form-actions"><button class="btn btn-outline" onclick="closeContractGenDialog()">取消</button><button class="btn btn-primary" onclick="doGenerateContract(\'' + orderType + '\',' + JSON.stringify(orderIds) + ')">生成合同</button></div></div>';
overlay.onclick = function(e) { if (e.target === overlay) {} };
document.body.appendChild(overlay);
})
.catch(function(err) {
_contractGenDialogOpen = false;
alert('获取模板列表失败: ' + err.message);
});
}
function closeContractGenDialog() {
_contractGenDialogOpen = false;
var el = document.getElementById('contractGenOverlay');
if (el) el.remove();
}
function doGenerateContract(orderType, orderIds) {
if (_contractGenerating) return;
var templateId = document.getElementById('contractGenTemplate').value;
_contractGenerating = true;
var btn = document.querySelector('#contractGenOverlay .btn-primary');
btn.disabled = true;
btn.textContent = '生成中...';
fetch('/api/contract/generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({order_ids: orderIds, order_type: orderType, template_id: parseInt(templateId)})
})
.then(function(r) { return r.json(); })
.then(function(res) {
_contractGenerating = false;
if (res.code !== 0) {
alert(res.msg || '生成失败');
return;
}
closeContractGenDialog();
// 显示生成结果
var resultHtml = '<div style="margin:10px 0">';
resultHtml += '<div style="font-size:14px;font-weight:600;margin-bottom:10px">✅ ' + res.msg + '</div>';
res.data.forEach(function(item) {
resultHtml += '<div style="padding:10px 14px;background:#f6ffed;border-radius:6px;margin-bottom:8px;font-size:13px">';
resultHtml += '<div><strong>' + escHtml(item.supplier) + '</strong> - 共 ' + item.product_count + ' 个产品</div>';
resultHtml += '<div style="margin-top:6px">合同编号: ' + escHtml(item.contract_no) + '</div>';
resultHtml += '<div style="margin-top:6px"><a href="' + item.file_path + '" class="btn btn-sm btn-primary" download="' + escHtml(item.file_name) + '">📥 下载合同</a></div>';
resultHtml += '</div>';
});
resultHtml += '</div>';
var overlay = document.createElement('div');
overlay.className = 'ct-form-overlay';
overlay.innerHTML = '<div class="ct-form-box" onclick="event.stopPropagation()" style="max-height:80vh;overflow-y:auto"><h2>📄 合同已生成</h2>' + resultHtml + '<div class="ct-form-actions"><button class="btn" onclick="this.closest(\'.ct-form-overlay\').remove()">关闭</button></div></div>';
overlay.onclick = function(e) { if (e.target === overlay) {} };
document.body.appendChild(overlay);
})
.catch(function(err) {
_contractGenerating = false;
alert('生成失败: ' + err);
});
}
// ─── 仪表盘 ───
function loadDashboard(container) {
container.innerHTML = `
<div class="page-header">
<h1>数据概览</h1>
<p class="page-desc">供应链运营数据总览</p>
</div>
<div id="dashContent"><div class="page-loading"><div class="loading-spinner"></div></div></div>
`;
fetch('/api/dashboard/stats')
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
// 缓存数据供图表重建
if (!window._pageDataCache) window._pageDataCache = {};
if (!window._pageReloaders) window._pageReloaders = {};
window._pageDataCache['dashboard'] = res.data;
window._pageReloaders['dashboard'] = function(data) {
renderDashboardCharts(data);
};
renderDashboardCharts(res.data);
});
}
function renderDashboardCharts(d) {
const el = document.getElementById('dashContent');
if (!el) return;
// 顶部统计卡片
var isAdmin = window._currentRole === 'admin' || window._currentRole === 'super_admin';
var statItems = [
{label:'SKU总数', value:(d.total_products || 0).toLocaleString(), sub:'', icon:'📦', color:'#1890ff'},
{label:'SPU总数', value:(d.spu_total || 0).toLocaleString(), sub:'', icon:'📦', color:'#2f54eb'},
{label:'库存成本', value:'¥' + Math.round(d.inventory_cost || 0).toLocaleString(), sub:'', icon:'💎', color:'#eb2f96'},
{label:'总供应商', value:d.total_suppliers || 0, sub:'家', icon:'🤝', color:'#13c2c2'},
{label:'低库存预警', value:d.low_stock_count || 0, sub:'', icon:'⚠️', color:'#f5222d'}
];
var cols = Math.min(statItems.length, 6);
let cards = '<div class="stats-grid" style="grid-template-columns:repeat(' + cols + ',1fr)">';
statItems.forEach(function(s) {
cards += '<div class="stat-card" style="border-left:4px solid ' + s.color + '">';
cards += '<div class="stat-icon">' + s.icon + '</div>';
cards += '<div class="stat-info">';
cards += '<div class="stat-value" style="color:' + s.color + '">' + s.value + '</div>';
cards += '<div class="stat-label">' + s.label + (s.sub ? ' <small style="color:#999">' + s.sub + '</small>' : '') + '</div>';
cards += '</div></div>';
});
cards += '</div>';
// 第一行图表:日出库趋势 + 品牌占比
var row1 = '<div style="display:flex;gap:15px;margin-top:20px">';
row1 += '<div class="card" style="flex:1.6"><div class="card-header"><h3>📈 日出库趋势</h3></div><div class="card-body" style="padding:12px 16px"><div style="height:280px"><canvas id="dashTrendChart"></canvas></div></div></div>';
row1 += '<div class="card" style="flex:1"><div class="card-header"><h3>🥧 品牌出库占比 TOP8</h3></div><div class="card-body" style="padding:12px 16px"><div style="height:280px"><canvas id="dashBrandChart"></canvas></div></div></div>';
row1 += '</div>';
// 第二行:店铺排行 + 品牌排行(并列纵向柱状图)
var row2 = '<div style="display:flex;gap:15px;margin-top:15px">';
row2 += '<div class="card" style="flex:1"><div class="card-header"><h3>🏪 店铺出库排行</h3></div><div class="card-body" style="padding:12px 16px"><div style="height:320px"><canvas id="dashShopChart"></canvas></div></div></div>';
row2 += '<div class="card" style="flex:1"><div class="card-header"><h3>🏷️ 品牌出库排行 TOP10</h3></div><div class="card-body" style="padding:12px 16px"><div style="height:320px"><canvas id="dashBrandRankChart"></canvas></div></div></div>';
row2 += '</div>';
el.innerHTML = cards + row1 + row2;
// 绘制所有图表
waitChartJS().then(function() {
var COLORS = ['#1890ff','#52c41a','#fa8c16','#722ed1','#13c2c2','#eb2f96','#faad14','#f5222d','#2f54eb','#a0d911'];
function drawTrend() {
var trendCanvas = document.getElementById('dashTrendChart');
if (trendCanvas && d.recent_trend && d.recent_trend.length > 0) {
new Chart(trendCanvas, {
type: 'bar',
data: {
labels: d.recent_trend.map(function(t) { return (t.date_str || t.date || '').slice(5); }),
datasets: [{ label: '出库量', data: d.recent_trend.map(function(t) { return parseInt(t.qty) || 0; }), backgroundColor: 'rgba(24,144,255,0.7)', borderColor: '#1890ff', borderWidth: 1, borderRadius: 4 }]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: function(v) { return v.toLocaleString(); } } }, x: { grid: { display: false } } } }
});
}
}
function drawBrand() {
var brandCanvas = document.getElementById('dashBrandChart');
if (brandCanvas && d.brand_stats && d.brand_stats.length > 0) {
var topBrands = d.brand_stats.slice(0, 8);
new Chart(brandCanvas, {
type: 'doughnut',
data: { labels: topBrands.map(function(b) { return b['品牌']; }), datasets: [{ data: topBrands.map(function(b) { return parseInt(b['总数量']) || 0; }), backgroundColor: COLORS.slice(0, topBrands.length), borderWidth: 2, borderColor: '#fff' }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { font: { size: 11 }, boxWidth: 12, padding: 6 } }, tooltip: { callbacks: { label: function(ctx) { var total = ctx.dataset.data.reduce(function(a,b) { return a + b; }, 0); return ctx.label + ': ' + ctx.raw.toLocaleString() + ' (' + ((ctx.raw / total) * 100).toFixed(1) + '%)'; } } } }, cutout: '55%' }
});
}
}
function drawShop() {
var shopCanvas = document.getElementById('dashShopChart');
if (shopCanvas && d.shop_stats && d.shop_stats.length > 0) {
var shopData = d.shop_stats.slice(0, 10);
new Chart(shopCanvas, {
type: 'bar',
data: { labels: shopData.map(function(s) { return (s['店铺'] || '').length > 10 ? (s['店铺'] || '').substring(0, 10) + '..' : (s['店铺'] || ''); }), datasets: [{ label: '出库量', data: shopData.map(function(s) { return parseInt(s['总数量']) || 0; }), backgroundColor: 'rgba(250,140,22,0.7)', borderColor: '#fa8c16', borderWidth: 1, borderRadius: 3 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: function(v) { return v.toLocaleString(); } } }, x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 30 } } } }
});
}
}
function drawBrandRank() {
var brandRankCanvas = document.getElementById('dashBrandRankChart');
if (brandRankCanvas && d.brand_stats && d.brand_stats.length > 0) {
var brandRankData = d.brand_stats.slice(0, 10);
new Chart(brandRankCanvas, {
type: 'bar',
data: { labels: brandRankData.map(function(b) { return b['品牌']; }), datasets: [{ label: '出库量', data: brandRankData.map(function(b) { return parseInt(b['总数量']) || 0; }), backgroundColor: 'rgba(114,46,209,0.7)', borderColor: '#722ed1', borderWidth: 1, borderRadius: 3 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: function(v) { return v.toLocaleString(); } } }, x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 30 } } } }
});
}
}
drawTrend();
drawBrand();
drawShop();
drawBrandRank();
});
}
// ─── 出库数据 ───
let _sumChart = null;
function loadDataSummaryPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>出库数据汇总</h1>
<p class="page-desc">按货品编号汇总每日出库量,点击SKU查看仓库/店铺明细</p>
</div>
<div class="card">
<div class="card-body" style="padding:12px 16px">
<div class="filter-bar" style="flex-wrap:wrap;gap:8px;align-items:center">
<input type="text" id="sumSearch" class="form-input" style="min-width:140px;flex:1;max-width:260px" placeholder="🔍 货品编号/货品名称" autocomplete="off" onkeydown="if(event.key==='Enter')loadSummaryData(1)">
<input type="date" id="sumDateStart" class="form-input" style="min-width:130px;flex:0 1 170px" onchange="loadSummaryData(1)">
<input type="date" id="sumDateEnd" class="form-input" style="min-width:130px;flex:0 1 170px" onchange="loadSummaryData(1)">
<button class="btn btn-primary" onclick="loadSummaryData(1)">查询</button>
<button class="btn" onclick="resetSummaryFilters()">重置</button>
<span class="data-count" id="sumCount"></span>
</div>
</div>
</div>
<!-- 汇总指标卡 -->
<div class="stats-grid" id="sumStatsGrid" style="grid-template-columns:repeat(4,1fr);margin-top:15px">
<div class="stat-card" style="border-left:4px solid #1890ff">
<div class="stat-icon">📦</div>
<div class="stat-info">
<div class="stat-value" id="sumTotalQty" style="color:#1890ff">-</div>
<div class="stat-label">总出库量 <small style="color:#999">件</small></div>
</div>
</div>
<div class="stat-card" style="border-left:4px solid #52c41a">
<div class="stat-icon">🏷️</div>
<div class="stat-info">
<div class="stat-value" id="sumTotalSku" style="color:#52c41a">-</div>
<div class="stat-label">出库SKU数 <small style="color:#999">个</small></div>
</div>
</div>
<div class="stat-card" style="border-left:4px solid #fa8c16">
<div class="stat-icon">📅</div>
<div class="stat-info">
<div class="stat-value" id="sumTotalDays" style="color:#fa8c16">-</div>
<div class="stat-label">出库天数 <small style="color:#999">天</small></div>
</div>
</div>
<div class="stat-card" style="border-left:4px solid #722ed1">
<div class="stat-icon">📊</div>
<div class="stat-info">
<div class="stat-value" id="sumAvgDaily" style="color:#722ed1">-</div>
<div class="stat-label">日均出库 <small style="color:#999">件</small></div>
</div>
</div>
</div>
<!-- 日出库趋势图 -->
<div class="card" style="margin-top:15px">
<div class="card-header"><h3>📈 日出库趋势</h3></div>
<div class="card-body" style="padding:12px 16px">
<div style="height:280px"><canvas id="sumTrendChart"></canvas></div>
</div>
</div>
<!-- 明细表格 -->
<div class="card" style="margin-top:15px">
<div class="card-header"><h3>📊 数据汇总</h3></div>
<div class="card-body" style="padding:0;overflow-x:auto">
<table class="data-table" id="sumTable">
<thead><tr><th style="text-align:center">序号</th><th style="text-align:center">日期</th><th style="text-align:center">货品编号</th><th style="text-align:center">货品名称</th><th style="text-align:center">品牌</th><th style="text-align:center">总出库量</th><th style="text-align:center">总成本</th></tr></thead>
<tbody id="sumTbody"><tr><td colspan="7" style="text-align:center;padding:40px;color:var(--text-light)">加载中...</td></tr></tbody>
</table>
<div id="sumPagination" class="pagination-controls" style="padding:8px 16px"></div>
</div>
</div>
<!-- SKU明细弹窗 -->
<div class="modal-overlay" id="skuModal" style="display:none">
<div class="modal" style="width:85vw;max-width:900px">
<div class="modal-header">
<h3>📦 出库明细 — <span id="skuModalTitle"></span></h3>
<span class="modal-close" onclick="closeSkuModal()">&times;</span>
</div>
<div class="modal-body" style="padding:16px 20px;max-height:65vh;overflow-y:auto">
<div style="margin-bottom:12px">
<span style="color:var(--text-light);font-size:13px">日期:</span>
<strong id="skuModalDate" style="font-size:14px"></strong>
<span style="color:var(--text-light);font-size:13px;margin-left:20px">货品:</span>
<strong id="skuModalName" style="font-size:14px"></strong>
</div>
<table class="data-table" id="skuDetailTable">
<thead><tr><th style="text-align:center">仓库</th><th style="text-align:center">店铺</th><th style="text-align:center">数据来源</th><th style="text-align:center">出库数量</th><th style="text-align:center">成本</th></tr></thead>
<tbody id="skuDetailTbody"></tbody>
</table>
<div id="skuDetailSummary" style="margin-top:10px;text-align:right;font-size:14px;font-weight:600;color:var(--text-primary)"></div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeSkuModal()">关闭</button>
</div>
</div>
</div>
`;
loadSummaryData(1);
}
let sumCurrentPage = 1;
let SUM_PAGE_SIZE = 20;
function loadSummaryData(page) {
sumCurrentPage = page;
var search = document.getElementById('sumSearch')?.value || '';
var start = document.getElementById('sumDateStart')?.value || '';
var end = document.getElementById('sumDateEnd')?.value || '';
// 加载指标卡和趋势图
loadSummaryStats(search);
// 加载明细表格
var url = '/api/data/summary?page=' + page + '&size=' + SUM_PAGE_SIZE;
if (search) url += '&search=' + encodeURIComponent(search);
if (start) url += '&date_from=' + start;
if (end) url += '&date_to=' + end;
fetch(url)
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
var data = res.data;
var tbody = document.getElementById('sumTbody');
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;color:var(--text-light)">暂无数据</td></tr>';
} else {
var startIdx = (page - 1) * SUM_PAGE_SIZE;
tbody.innerHTML = data.rows.map(function(r, i) {
return '<tr><td style="text-align:center">' + (startIdx + i + 1) + '</td><td style="text-align:center">' + escHtml(r.日期 || '-') + '</td><td style="text-align:center"><a href="javascript:;" onclick="showSkuDetail(\'' + escHtml(r.货品编号) + '\', \'' + escHtml(r.日期) + '\', \'' + escJs(r.货品名称) + '\')" class="sku-link"><code>' + escHtml(r.货品编号) + '</code></a></td><td style="text-align:center">' + escHtml(r.货品名称 || '-') + '</td><td style="text-align:center">' + escHtml(r.品牌 || '-') + '</td><td style="text-align:center;font-weight:600;color:#1890ff">' + (r.total_qty || 0) + '</td><td style="text-align:center">' + (r.total_cost || 0).toFixed(2) + '</td></tr>';
}).join('');
}
document.getElementById('sumCount').textContent = '共 ' + (data.total || 0) + ' 条';
var pel = document.getElementById('sumPagination');
if (pel) renderPaginationWithSize(pel, page, data.total || 0, SUM_PAGE_SIZE, 'loadSummaryData', 'SUM_PAGE_SIZE');
});
}
let _sumChartMonth = null; // 当前钻取的月份
function loadSummaryStats(search) {
var url = '/api/data/summary-stats?group_by=month';
if (search) url += '&search=' + encodeURIComponent(search);
fetch(url)
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
var d = res.data;
document.getElementById('sumTotalQty').textContent = (d.total_out_qty || 0).toLocaleString();
document.getElementById('sumTotalSku').textContent = (d.total_sku || 0).toLocaleString();
document.getElementById('sumTotalDays').textContent = (d.total_days || 0).toLocaleString();
var avg = d.total_days > 0 ? Math.round((d.total_out_qty || 0) / d.total_days) : 0;
document.getElementById('sumAvgDaily').textContent = avg.toLocaleString();
// 绘制月度趋势图
waitChartJS().then(function() {
if (_sumChart) {
_sumChart.destroy();
_sumChart = null;
}
var canvas = document.getElementById('sumTrendChart');
if (!canvas || !d.trend || d.trend.length === 0) return;
_sumChartMonth = null;
renderTrendChart(canvas, d.trend, 'month', null, search);
});
});
}
function renderTrendChart(canvas, data, mode, month, search) {
var labels, values, labelText;
var dataKey = (mode === 'day') ? 'day_qty' : 'month_qty';
var labelKey = (mode === 'day') ? '日期' : 'month';
if (mode === 'day') {
labels = data.map(function(t) { return (t[labelKey] || '').slice(5); });
labelText = month + ' 日出库趋势';
} else {
labels = data.map(function(t) { return t[labelKey] || ''; });
labelText = '月度出库趋势(点击月份钻取)';
}
values = data.map(function(t) { return t[dataKey] || 0; });
var barColor = mode === 'day' ? 'rgba(82,196,26,0.6)' : 'rgba(24,144,255,0.5)';
var lineColor = mode === 'day' ? '#52c41a' : '#1890ff';
_sumChart = new Chart(canvas, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '出库量',
data: values,
backgroundColor: barColor,
borderColor: barColor,
borderWidth: 1,
borderRadius: 2,
order: 2
}, {
label: '趋势',
type: 'line',
data: values,
borderColor: lineColor,
backgroundColor: 'transparent',
borderWidth: 2,
tension: 0.3,
pointRadius: 4,
pointBackgroundColor: '#fff',
pointBorderColor: lineColor,
pointBorderWidth: 2,
pointHoverRadius: 6,
fill: false,
order: 1
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: function(items) {
if (mode === 'day') return month + '-' + items[0].label;
return items[0].label;
},
label: function(ctx) {
return '出库量: ' + ctx.raw.toLocaleString();
}
}
}
},
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: function(v) { return v.toLocaleString(); } } },
x: { grid: { display: false } }
},
onClick: function(e) {
if (mode === 'month') {
var active = _sumChart.getElementsAtEventForMode(e, 'index', {intersect: true});
if (active.length > 0) {
var idx = active[0].index;
var monthLabel = _sumChart.data.labels[idx];
drillToMonth(monthLabel, search);
}
} else if (mode === 'day') {
// 日视图点击返回月视图
var search = document.getElementById('sumSearch')?.value || '';
loadSummaryStats(search);
}
}
}
});
// 更新图表标题
var headerEl = canvas.closest('.card')?.querySelector('.card-header h3');
if (headerEl) {
if (mode === 'day') {
headerEl.innerHTML = '📈 ' + month + ' 日出库趋势 <span style="font-size:12px;color:var(--text-light);font-weight:normal;cursor:pointer" onclick="loadSummaryStats(document.getElementById(\'sumSearch\')?.value || \'\')">(点击返回月视图)</span>';
} else {
headerEl.innerHTML = '📈 月度出库趋势 <span style="font-size:12px;color:var(--text-light);font-weight:normal">(点击数据点钻取日数据)</span>';
}
}
}
function drillToMonth(month, search) {
_sumChartMonth = month;
var url = '/api/data/summary-stats?group_by=day&month=' + encodeURIComponent(month);
if (search) url += '&search=' + encodeURIComponent(search);
fetch(url)
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
var d = res.data;
if (_sumChart) {
_sumChart.destroy();
_sumChart = null;
}
var canvas = document.getElementById('sumTrendChart');
if (!canvas || !d.trend || d.trend.length === 0) return;
renderTrendChart(canvas, d.trend, 'day', month, search);
});
}
window.showSkuDetail = function(productCode, dateStr, productName) {
document.getElementById('skuModalTitle').textContent = productCode;
document.getElementById('skuModalDate').textContent = dateStr;
document.getElementById('skuModalName').textContent = productName || '-';
document.getElementById('skuDetailTbody').innerHTML = '<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--text-light)">加载中...</td></tr>';
document.getElementById('skuDetailSummary').textContent = '';
document.getElementById('skuModal').style.display = 'flex';
fetch('/api/data/sku-detail?product_code=' + encodeURIComponent(productCode) + '&date=' + encodeURIComponent(dateStr))
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
var details = res.data.details || [];
var tbody = document.getElementById('skuDetailTbody');
if (details.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:20px;color:var(--text-light)">暂无明细</td></tr>';
} else {
var SOURCE_MAP = {'wdt':'旺店通','jdbz':'京东泊紫','jdgj':'京东国际','jdjwt':'京东洁维塔','tmgj':'天猫国际'};
var totalQty = 0, totalCost = 0;
tbody.innerHTML = details.map(function(d) {
totalQty += d.qty || 0;
totalCost += d.cost || 0;
var srcCn = SOURCE_MAP[d.数据来源] || d.数据来源 || '-';
return '<tr><td style="text-align:center">' + escHtml(d.仓库 || '-') + '</td><td style="text-align:center">' + escHtml(d.店铺 || '-') + '</td><td style="text-align:center">' + srcCn + '</td><td style="text-align:center;font-weight:600">' + (d.qty || 0) + '</td><td style="text-align:center">' + (d.cost || 0).toFixed(2) + '</td></tr>';
}).join('');
document.getElementById('skuDetailSummary').textContent = '合计:' + totalQty + ' 件,' + totalCost.toFixed(2);
}
});
};
window.closeSkuModal = function() {
document.getElementById('skuModal').style.display = 'none';
};
window.resetSummaryFilters = function() {
document.getElementById('sumSearch').value = '';
document.getElementById('sumDateStart').value = '';
document.getElementById('sumDateEnd').value = '';
loadSummaryData(1);
};
function loadDataPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>出库数据</h1>
<p class="page-desc">每日出库明细数据</p>
</div>
<div class="card">
<div class="card-body" style="padding:12px 16px">
<div class="filter-bar" style="flex-wrap:wrap;gap:8px;align-items:center">
<input type="text" id="dataSearch" class="form-input" style="width:200px" placeholder="🔍 货品编号/货品名称" autocomplete="off" onkeydown="if(event.key==='Enter')loadDataPageData(1)">
<input type="date" id="dataDateStart" class="form-input" style="width:135px" onchange="loadDataPageData(1)">
<input type="date" id="dataDateEnd" class="form-input" style="width:135px" onchange="loadDataPageData(1)">
<select id="dataWarehouse" class="form-input" style="width:120px" onchange="loadDataPageData(1)"><option value="">全部仓库</option></select>
<select id="dataShop" class="form-input" style="width:120px" onchange="loadDataPageData(1)"><option value="">全部店铺</option></select>
<select id="dataBrand" class="form-input" style="width:120px" onchange="loadDataPageData(1)"><option value="">全部品牌</option></select>
<select id="dataSource" class="form-input" style="width:110px" onchange="loadDataPageData(1)"><option value="">全部来源</option></select>
<button class="btn btn-primary" onclick="loadDataPageData(1)">查询</button>
<button class="btn" onclick="resetDataFilters()">重置</button>
<span class="data-count" id="dataCount"></span>
</div>
</div>
</div>
<div class="card" style="margin-top:15px">
<div class="card-header"><h3>出库列表</h3></div>
<div class="card-body" style="padding:0;overflow-x:auto">
<table class="data-table" id="dataTable">
<thead><tr><th style="text-align:center">序号</th><th style="text-align:center;white-space:nowrap">日期</th><th style="text-align:center">货品编号</th><th style="text-align:center">货品名称</th><th style="text-align:center">品牌</th><th style="text-align:center">货品数量</th><th style="text-align:center">成本价</th><th style="text-align:center">金额</th><th style="text-align:center">仓库</th><th style="text-align:center">店铺</th><th style="text-align:center">数据来源</th></tr></thead>
<tbody id="dataTbody"><tr><td colspan="11" style="text-align:center;padding:40px;color:var(--text-light)">加载中...</td></tr></tbody>
</table>
<div id="dataPagination" class="pagination-controls" style="padding:8px 16px"></div>
</div>
</div>
`;
loadDataPageData(1);
// 加载筛选选项
fetch('/api/data/dates')
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
var d = res.data;
var srcSel = document.getElementById('dataSource');
if (srcSel && d.sources) {
d.sources.forEach(function(w) {
var cn = SOURCE_MAP_CN[w] || w;
srcSel.innerHTML += '<option value="' + escHtml(w) + '">' + cn + '</option>';
});
}
var whSel = document.getElementById('dataWarehouse');
if (whSel && d.warehouses) {
d.warehouses.forEach(function(w) {
whSel.innerHTML += '<option value="' + escHtml(w) + '">' + escHtml(w) + '</option>';
});
}
var shSel = document.getElementById('dataShop');
if (shSel && d.shops) {
d.shops.forEach(function(s) {
shSel.innerHTML += '<option value="' + escHtml(s) + '">' + escHtml(s) + '</option>';
});
}
var brSel = document.getElementById('dataBrand');
if (brSel && d.brands) {
d.brands.forEach(function(b) {
brSel.innerHTML += '<option value="' + escHtml(b) + '">' + escHtml(b) + '</option>';
});
}
});
}
const SOURCE_MAP_CN = {
'wdt': '旺店通',
'jdbz': '京东泊紫',
'jdgj': '京东国际',
'jdjwt': '京东洁维塔',
'tmgj': '天猫国际'
};
let dataCurrentPage = 1;
let DATA_PAGE_SIZE = 20;
function loadDataPageData(page) {
dataCurrentPage = page;
var search = document.getElementById('dataSearch')?.value || '';
var start = document.getElementById('dataDateStart')?.value || '';
var end = document.getElementById('dataDateEnd')?.value || '';
var warehouse = document.getElementById('dataWarehouse')?.value || '';
var shop = document.getElementById('dataShop')?.value || '';
var brand = document.getElementById('dataBrand')?.value || '';
var source = document.getElementById('dataSource')?.value || '';
var url = '/api/data/list?page=' + page + '&size=' + DATA_PAGE_SIZE;
if (search) url += '&search=' + encodeURIComponent(search);
if (start) url += '&date_from=' + start;
if (end) url += '&date_to=' + end;
if (warehouse) url += '&warehouse=' + encodeURIComponent(warehouse);
if (shop) url += '&shop=' + encodeURIComponent(shop);
if (brand) url += '&brand=' + encodeURIComponent(brand);
if (source) url += '&source=' + encodeURIComponent(source);
fetch(url)
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
var data = res.data;
var tbody = document.getElementById('dataTbody');
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:40px;color:var(--text-light)">暂无数据</td></tr>';
} else {
var startIdx = (page - 1) * DATA_PAGE_SIZE;
tbody.innerHTML = data.rows.map(function(r, i) {
var srcCn = SOURCE_MAP_CN[r.数据来源] || r.数据来源 || '-';
var wh = (r.数据来源 === 'wdt') ? ((r.仓库 || '').replace(/"/g, '') || '-') : '-';
var sh = (r.数据来源 === 'wdt') ? (r.店铺 || '-') : '-';
var totalCost = parseFloat(r.货品成本) || 0;
var qty = parseInt(r.货品数量) || 0;
// CSV 中的"货品成本"是该行总成本,不是单价
// 单价 = 总成本 / 数量,金额 = 总成本
var unitCost = (qty > 0 && totalCost > 0) ? totalCost / qty : 0;
var unitCostStr = unitCost > 0 ? unitCost.toFixed(4) : '-';
var amountStr = totalCost > 0 ? totalCost.toFixed(2) : '-';
return '<tr><td style="text-align:center">' + (startIdx + i + 1) + '</td><td style="text-align:center;white-space:nowrap">' + escHtml(r.日期 || '-') + '</td><td style="text-align:center"><code>' + escHtml(r.货品编号 || '-') + '</code></td><td style="text-align:center">' + escHtml(r.货品名称 || '-') + '</td><td style="text-align:center">' + escHtml(r.品牌 || '-') + '</td><td style="text-align:center">' + qty + '</td><td style="text-align:center">' + unitCostStr + '</td><td style="text-align:center">' + amountStr + '</td><td style="text-align:center">' + escHtml(wh) + '</td><td style="text-align:center">' + escHtml(sh) + '</td><td style="text-align:center">' + srcCn + '</td></tr>';
}).join('');
}
document.getElementById('dataCount').textContent = '共 ' + (data.total || 0) + ' 条';
var pel = document.getElementById('dataPagination');
if (pel) renderPaginationWithSize(pel, page, data.total || 0, DATA_PAGE_SIZE, 'loadDataPageData', 'DATA_PAGE_SIZE');
});
}
window.searchData = function() { loadDataPageData(1); };
window.resetDataFilters = function() {
document.getElementById('dataSearch').value = '';
document.getElementById('dataDateStart').value = '';
document.getElementById('dataDateEnd').value = '';
document.getElementById('dataWarehouse').value = '';
document.getElementById('dataShop').value = '';
document.getElementById('dataBrand').value = '';
document.getElementById('dataSource').value = '';
loadDataPageData(1);
};
// ─── 产品基础表 ───
// 所有产品列定义
const PROD_COLUMNS = [
{field: 'product_code', label: '货品编号', defaultShow: true, sortable: true},
{field: 'product_name', label: '货品名称', defaultShow: true, sortable: true},
{field: 'product_type', label: '货品类型', defaultShow: true, sortable: false},
{field: 'brand', label: '品牌', defaultShow: true, sortable: true},
{field: 'supplier_code', label: '供应商编码', defaultShow: true, sortable: false},
{field: 'supplier_name_match', label: '供应商名称', defaultShow: true, sortable: false},
{field: 'lead_time', label: '货期(天)', defaultShow: true, sortable: false},
{field: 'logistics_time', label: '物流时间(天)', defaultShow: true, sortable: false},
{field: 'procurement_follower', label: '采购跟单', defaultShow: true, sortable: false},
{field: 'procurement_planner', label: '采购计划', defaultShow: true, sortable: false},
{field: 'platform', label: '平台', defaultShow: true, sortable: false},
{field: 'operator', label: '运营', defaultShow: true, sortable: false},
{field: 'developer', label: '开发专员', defaultShow: true, sortable: false},
{field: 'created_at', label: '创建时间', defaultShow: true, sortable: true},
{field: 'import_date', label: '导入日期', defaultShow: true, sortable: false},
];
// 字段显隐状态
if (!window.prodColVisible) {
window.prodColVisible = {};
PROD_COLUMNS.forEach(c => { window.prodColVisible[c.field] = c.defaultShow; });
}
function toggleProdCol(field) {
window.prodColVisible[field] = !window.prodColVisible[field];
document.querySelectorAll(`.col-${field}`).forEach(el => {
el.style.display = window.prodColVisible[field] ? '' : 'none';
});
const cb = document.getElementById('col-cb-' + field);
if (cb) cb.checked = window.prodColVisible[field];
}
// ─── 产品基础表 ───
let prodCurrentPage = 1;
let PROD_PAGE_SIZE = 50;
let prodSortBy = '';
let prodSortAsc = true;
let prodActiveTab = '成品'; // '成品' | '非成品'
function loadProductsPage(container) {
container.innerHTML = `
<style>
.prod-tabs { display:flex;gap:4px;margin-bottom:14px;background:var(--bg-card,#f7f8fa);padding:4px;border-radius:10px; }
.prod-tab { padding:8px 22px;font-size:13px;font-weight:600;cursor:pointer;color:var(--text-light,#888);border-radius:8px;transition:all .25s;user-select:none; }
.prod-tab:hover { color:var(--text,#333);background:rgba(0,0,0,0.04); }
.prod-tab:nth-child(1).active { color:#1890ff;background:#e6f7ff;box-shadow:0 1px 4px rgba(24,144,255,0.12); }
.prod-tab:nth-child(2).active { color:#f5a623;background:#fff7e6;box-shadow:0 1px 4px rgba(245,166,35,0.12); }
</style>
<div class="page-header" style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">
<h1 style="margin:0;flex-shrink:0">产品信息管理</h1>
<div style="display:inline-flex;align-items:center;gap:4px;font-size:13px;color:var(--text-light,#999);padding:2px 8px;background:#f5f5f5;border-radius:4px;white-space:nowrap;cursor:default">
<span style="color:#1890ff;font-weight:500;text-decoration:none;cursor:pointer" onclick="window.open('/suppliers','_self')">供应商管理</span>
<span style="font-size:12px;color:#ccc">→</span>
<span>产品信息与供应商关联数据</span>
</div>
</div>
<div class="prod-tabs" id="prodTabs">
<div class="prod-tab active" data-type="成品" onclick="switchProdTab('成品')">成品基础信息表</div>
<div class="prod-tab" data-type="非成品" onclick="switchProdTab('非成品')">包材基础信息表</div>
</div>
<div class="card">
<div class="card-body" style="padding:12px 16px">
<div class="filter-bar" style="flex-wrap:wrap;gap:8px">
<input type="text" id="prodCode" class="form-input" style="width:110px" placeholder="货品编号" autocomplete="off" onkeydown="if(event.key==='Enter')searchProducts()">
<input type="text" id="prodName" class="form-input" style="width:130px" placeholder="货品名称" autocomplete="off" onkeydown="if(event.key==='Enter')searchProducts()">
<input type="text" id="prodSupplier" class="form-input" style="width:120px" placeholder="供应商" autocomplete="off" onkeydown="if(event.key==='Enter')searchProducts()">
<select id="prodTypeFilter" class="form-input" style="width:110px;padding:6px 8px">
<option value="">货品类型</option>
<option value="成品">成品</option>
<option value="现货">现货</option>
<option value="清库存">清库存</option>
<option value="赠品">赠品</option>
<option value="原材料">原材料</option>
</select>
<button class="btn btn-primary" onclick="searchProducts()">查询</button>
<button class="btn" onclick="resetProductFilters()">重置</button>
<span class="data-count" id="prodCount"></span>
</div>
</div>
</div>
<div class="card" style="margin-top:15px">
<div class="card-header" style="display:flex;align-items:center;gap:8px">
<h3 style="flex:0 0 auto" id="prodListTitle">成品信息列表</h3>
<button class="btn btn-sm btn-primary" onclick="openProductAddModal()">+ 新增</button>
<div style="margin-left:auto;display:flex;gap:6px" id="prodToolbar">
<button class="btn btn-sm" onclick="openRecycleBin()" style="color:#f5222d;border-color:#f5222d">🗑 回收站</button>
<button class="btn btn-sm" onclick="exportProducts()">📤 导出</button>
<button class="btn btn-sm" onclick="openProductImportModal()">📥 导入</button>
<button class="btn btn-sm" onclick="openProdColModal()">⚙ 字段设置</button>
</div>
</div>
<div class="card-body" style="padding:0;overflow-x:auto">
<table class="data-table" id="prodTable"><thead id="prodThead" style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)"></thead><tbody id="prodTbody"></tbody></table>
<div id="prodPagination" class="pagination-controls" style="padding:8px 16px"></div>
</div>
</div>
`;
// 构建列头
buildProdThead();
// 添加字段设置弹窗 HTML
const prodColModalHtml = `
<div class="modal-overlay" id="prodColModal" style="display:none">
<div class="modal" style="width:720px">
<div class="modal-header">
<h3>字段设置</h3>
<span class="modal-close" onclick="closeProdColModal()">&times;</span>
</div>
<div class="modal-body" style="max-height:65vh;overflow-y:auto;padding:16px 20px">
<div class="col-toggle-all" style="padding-bottom:8px;border-bottom:1px solid var(--border);margin-bottom:8px">
<label class="cb-all"><input type="checkbox" id="prod-cb-all" checked onchange="toggleProdColPendingAll(this.checked)"> <strong>全选</strong></label>
</div>
<input type="text" id="prodColSearchInput" class="form-input" placeholder="搜索字段名称..." oninput="filterProdColCheckboxes()" style="margin-bottom:8px;width:100%;box-sizing:border-box">
<div id="prodColCheckboxes">
<div id="prodColGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px 12px;padding:4px 0"></div>
</div>
</div>
<div class="modal-footer" style="display:flex;justify-content:space-between;align-items:center">
<div>
<button class="btn btn-outline" onclick="resetProdCol()" style="font-size:12px;color:#888">↺ 恢复默认</button>
</div>
<div style="display:flex;gap:8px">
<button class="btn" onclick="closeProdColModal()">取消</button>
<button class="btn btn-primary" onclick="confirmProdCol()">确定</button>
</div>
</div>
</div>
</div>
`;
if (!document.getElementById('prodColModal')) {
container.insertAdjacentHTML('afterend', prodColModalHtml);
}
// 初始化拖拽顺序
window._prodColDragOrder = PROD_COLUMNS.map(c => c.field);
window._prodColDefaultOrder = PROD_COLUMNS.map(c => c.field);
if (!window._prodColPendingVisible) {
window._prodColPendingVisible = {};
PROD_COLUMNS.forEach(c => { window._prodColPendingVisible[c.field] = c.defaultShow; });
}
renderProdColCheckboxes();
// 清除筛选框搜索记忆
['prodCode','prodName','prodSupplier','prodTypeFilter'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
// 加载
prodActiveTab = '成品';
updateProdTypeFilterOptions('成品');
applyProdTabColumns('成品');
loadProducts(1);
}
function applyProdTabColumns(tab) {
// 包材基础信息表:跳过不需要的列(buildProdThead+loadProducts根据状态跳过渲染)
// 成品基础信息表:恢复所有列显示
var hideFields = ['brand','lead_time','logistics_time','procurement_follower','procurement_planner','platform','operator','developer','created_at'];
hideFields.forEach(function(f) {
window.prodColVisible[f] = tab !== '非成品';
});
// 字段设置checkbox状态同步
document.querySelectorAll('#prodColModal input[type="checkbox"]').forEach(function(cb) {
var field = cb.id && cb.id.replace('col-cb-', '');
if (field && window.prodColVisible[field] !== undefined) cb.checked = window.prodColVisible[field];
});
}
function switchProdTab(tab) {
prodActiveTab = tab;
document.querySelectorAll('#prodTabs .prod-tab').forEach(function(el) {
el.classList.toggle('active', el.dataset.type === tab);
});
document.getElementById('prodListTitle').textContent = tab === '成品' ? '成品列表' : '包材列表';
// 切页清空筛选框,互不影响
['prodCode','prodName','prodSupplier','prodTypeFilter'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
// 更新货品类型下拉选项
updateProdTypeFilterOptions(tab);
applyProdTabColumns(tab);
// 重建表头使列显隐与当前tab一致
buildProdThead();
loadProducts(1);
}
function updateProdTypeFilterOptions(tab) {
var sel = document.getElementById('prodTypeFilter');
if (!sel) return;
var opts = '';
if (tab === '成品') {
// 成品基础表展示全部产品,下拉包含所有类型供筛选
opts = '<option value="">全部类型</option><option value="成品">成品</option><option value="现货">现货</option><option value="清库存">清库存</option><option value="赠品">赠品</option><option value="原材料">原材料</option>';
} else {
opts = '<option value="">全部类型</option><option value="包材">包材</option><option value="其他">其他</option>';
}
sel.innerHTML = opts;
}
function buildProdThead() {
var thead = document.getElementById('prodThead');
if (!thead) return;
var theadHtml = '<tr><th style="width:50px">序号</th>';
PROD_COLUMNS.forEach(function(c) {
var vis = window.prodColVisible[c.field] ? '' : ' style="display:none"';
var sortAttr = c.sortable ? ' data-sort="' + c.field + '" class="sortable"' : '';
theadHtml += '<th' + sortAttr + ' class="col-' + c.field + '"' + vis + '>' + c.label + '</th>';
});
theadHtml += '<th class="col-action" style="width:120px;min-width:120px;position:sticky;right:0;background:var(--bg,#fff);z-index:3;border-left:1px solid var(--border);text-align:center">操作</th>';
theadHtml += '</tr>';
thead.innerHTML = theadHtml;
}
function loadProducts(page) {
prodCurrentPage = page;
var code = (document.getElementById('prodCode')?.value || '').trim();
var name = (document.getElementById('prodName')?.value || '').trim();
var supplier = (document.getElementById('prodSupplier')?.value || '').trim();
var typeFilter = document.getElementById('prodTypeFilter')?.value || '';
var url = '/api/products/list?page=' + page + '&size=' + PROD_PAGE_SIZE;
if (code) url += '&prod_code=' + encodeURIComponent(code);
if (name) url += '&prod_name=' + encodeURIComponent(name);
if (supplier) url += '&supplier=' + encodeURIComponent(supplier);
if (prodActiveTab === '成品') {
if (typeFilter) {
url += '&product_type=' + encodeURIComponent(typeFilter);
} else {
// 成品基础表:排除包材和其他类
url += '&product_type=' + encodeURIComponent('非包材');
}
} else {
if (typeFilter) {
url += '&product_type=' + encodeURIComponent(typeFilter);
} else {
// 非成品基础表:仅展示包材和其他
url += '&product_type=' + encodeURIComponent('非成品专用');
}
}
if (prodSortBy) url += '&sort_by=' + encodeURIComponent(prodSortBy) + '&sort_order=' + (prodSortAsc ? 'asc' : 'desc');
// 缓存当前页数据供快速编辑
if (!window._prodRowCache) window._prodRowCache = {};
fetch(url)
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
var data = res.data;
var tbody = document.getElementById('prodTbody');
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="' + (PROD_COLUMNS.length + 1) + '" style="text-align:center;padding:40px;color:var(--text-light)">暂无数据</td></tr>';
} else {
var startIdx = (page - 1) * PROD_PAGE_SIZE;
// 缓存行数据
data.rows.forEach(function(r) { if (r.product_code) window._prodRowCache[r.product_code] = r; });
tbody.innerHTML = data.rows.map(function(r, i) {
var html = '<td>' + (startIdx + i + 1) + '</td>';
PROD_COLUMNS.forEach(function(c) {
if (!window.prodColVisible[c.field]) return;
var raw = r[c.field];
var val = escHtml(raw || '-');
if (c.field === 'product_code') {
val = '<code>' + val + '</code>';
}
if (c.field === 'product_type') {
var typeColors = {'成品':'tag-blue','包材':'tag-orange','赠品':'tag-green','小样':'tag-purple','物料':'tag-orange'};
var cls = typeColors[raw] || 'tag-grey';
val = '<span class="tag ' + cls + '">' + val + '</span>';
}
if (c.field === 'cost_price') {
var p = parseFloat(raw);
val = isNaN(p) ? '-' : '¥' + p.toFixed(2);
}
if (c.field === 'created_at') {
val = escHtml((raw || '').toString().slice(0, 10) || '-');
}
html += '<td class="col-' + c.field + '">' + val + '</td>';
});
var pcode = r.product_code || '';
html += '<td class="col-action" style="position:sticky;right:0;z-index:2;background:var(--bg,#fff);border-left:1px solid var(--border);white-space:nowrap">';
html += '<button class="btn btn-sm btn-outline-primary" onclick="viewProductDetail(\'' + escHtml(pcode) + '\')" title="详情" style="font-size:11px;color:#1890ff;border-color:#1890ff">详情</button> ';
html += '<button class="btn btn-sm btn-outline" onclick="quickEditProduct(\'' + escHtml(pcode) + '\')" title="编辑" style="font-size:11px;color:#fa8c16;border-color:#fa8c16">编辑</button> ';
if (prodActiveTab === '成品') html += '<button class="btn btn-sm btn-outline" onclick="viewProductBom(\'' + escHtml(pcode) + '\')" title="查看BOM清单" style="font-size:11px;color:#722ed1;border-color:#722ed1">📋 BOM</button> ';
html += '<button class="btn btn-sm btn-outline-danger" onclick="deleteProduct(\'' + escHtml(pcode) + '\')" title="删除" style="font-size:11px;color:#f5222d;border-color:#f5222d">删除</button>';
return '<tr>' + html + '</tr>';
}).join('');
}
document.getElementById('prodCount').textContent = '共 ' + (data.total || 0) + ' 条';
var pel = document.getElementById('prodPagination');
if (pel) renderPaginationWithSize(pel, page, data.total || 0, PROD_PAGE_SIZE, 'loadProducts', 'PROD_PAGE_SIZE');
});
}
window.searchProducts = function() { loadProducts(1); };
window.resetProductFilters = function() {
document.getElementById('prodCode').value = '';
document.getElementById('prodName').value = '';
document.getElementById('prodSupplier').value = '';
var tf = document.getElementById('prodTypeFilter');
if (tf) tf.value = '';
loadProducts(1);
};
// ─── 新增产品 ───
function openProductAddModal() {
var modal = document.getElementById('prodAddModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'prodAddModal';
modal.className = 'modal-overlay';
modal.style.display = 'flex';
modal.innerHTML = `
<div class="modal" style="width:560px">
<div class="modal-header">
<h3>新增产品</h3>
<span class="modal-close" onclick="closeProductAddModal()">&times;</span>
</div>
<div class="modal-body" style="max-height:70vh;overflow-y:auto;padding:16px 20px">
<div id="prodAddForm">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px 16px">
<div><label style="font-size:12px;color:var(--text-secondary)">货品编号 *</label><input id="pa_code" class="form-input" style="width:100%;box-sizing:border-box" placeholder="必填" autocomplete="off"></div>
<div><label style="font-size:12px;color:var(--text-secondary)">货品名称 *</label><input id="pa_name" class="form-input" style="width:100%;box-sizing:border-box" placeholder="必填" autocomplete="off"></div>
<div><label style="font-size:12px;color:var(--text-secondary)"><span style="color:#f5222d">*</span> 品牌</label><input id="pa_brand" class="form-input" style="width:100%;box-sizing:border-box" placeholder="必填" required autocomplete="off"></div>
<div><label style="font-size:12px;color:var(--text-secondary)"><span style="color:#f5222d">*</span> 货品类型</label>
<select id="pa_type" class="form-input" style="width:100%">
<option value="">请选择</option>
<option value="成品">成品</option>
<option value="清库存">清库存</option>
<option value="现货">现货</option>
<option value="赠品">赠品</option>
<option value="原材料">原材料</option>
</select>
</div>
<div><label style="font-size:12px;color:var(--text-secondary)"><span style="color:#f5222d">*</span> 供应商编码</label><input id="pa_supcode" class="form-input" style="width:100%;box-sizing:border-box;background:#f5f5f5;color:#999" readonly placeholder="自动匹配"></div>
<div style="position:relative"><label style="font-size:12px;color:var(--text-secondary)"><span style="color:#f5222d">*</span> 供应商名称</label><input id="pa_supname" class="form-input" style="width:100%;box-sizing:border-box" placeholder="输入简称自动匹配" required autocomplete="off" oninput="lookupAddSupplierName(this.value)"><div id="pa_supDropdown" style="position:absolute;top:100%;left:0;right:0;z-index:100;background:#fff;border:1px solid #d9d9d9;border-radius:4px;max-height:200px;overflow-y:auto;display:none;box-shadow:0 4px 12px rgba(0,0,0,0.12)"></div></div>
<div><label style="font-size:12px;color:var(--text-secondary)"><span style="color:#f5222d">*</span> 货期(天)</label><input id="pa_lead" type="number" class="form-input" style="width:100%;box-sizing:border-box" placeholder="必填" required autocomplete="off"></div>
<div><label style="font-size:12px;color:var(--text-secondary)"><span style="color:#f5222d">*</span> 物流时间(天)</label><input id="pa_logis" type="number" class="form-input" style="width:100%;box-sizing:border-box" placeholder="必填" required max="999" autocomplete="off"></div>
<div style="position:relative"><label style="font-size:12px;color:var(--text-secondary)"><span style="color:#f5222d">*</span> 采购跟单</label><input id="pa_follower" class="form-input" style="width:100%;box-sizing:border-box" placeholder="必填" required autocomplete="off" onfocus="showFollowerHistory()" oninput="showFollowerHistory()"><div id="pa_folDropdown" style="position:absolute;top:100%;left:0;right:0;z-index:100;background:#fff;border:1px solid #d9d9d9;border-radius:4px;max-height:150px;overflow-y:auto;display:none;box-shadow:0 4px 12px rgba(0,0,0,0.12)"></div></div>
<div><label style="font-size:12px;color:var(--text-secondary)"><span style="color:#f5222d">*</span> 采购计划</label><input id="pa_planner" class="form-input" style="width:100%;box-sizing:border-box" placeholder="必填" required autocomplete="off"></div>
<div><label style="font-size:12px;color:var(--text-secondary)">创建时间</label><input id="pa_created" type="date" class="form-input" style="width:100%;box-sizing:border-box" autocomplete="off"></div>
<div><label style="font-size:12px;color:var(--text-secondary)">平台</label><input id="pa_platform" class="form-input" style="width:100%;box-sizing:border-box" placeholder="选填" autocomplete="off"></div>
<div><label style="font-size:12px;color:var(--text-secondary)">运营</label><input id="pa_operator" class="form-input" style="width:100%;box-sizing:border-box" placeholder="选填" autocomplete="off"></div>
<div><label style="font-size:12px;color:var(--text-secondary)">开发专员</label><input id="pa_developer" class="form-input" style="width:100%;box-sizing:border-box" placeholder="选填" autocomplete="off"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeProductAddModal()">取消</button>
<button class="btn btn-primary" onclick="submitProductAdd()">保存</button>
</div>
</div>
`;
document.body.appendChild(modal);
updateAddTypeOptions();
updateAddFieldsVisibility();
} else {
modal.style.display = 'flex';
// 清空表单
modal.querySelectorAll('input, select').forEach(function(el) { el.value = ''; });
updateAddTypeOptions();
updateAddFieldsVisibility();
}
}
function updateAddFieldsVisibility() {
var hideIds = ['pa_brand','pa_lead','pa_logis','pa_follower','pa_planner','pa_platform','pa_operator','pa_developer','pa_created'];
var isNonProd = prodActiveTab === '非成品';
hideIds.forEach(function(id) {
var el = document.getElementById(id);
// el的父级div就是grid的直接子元素
if (el && el.parentElement) {
el.parentElement.style.display = isNonProd ? 'none' : '';
}
});
}
function updateAddTypeOptions() {
var sel = document.getElementById('pa_type');
if (!sel) return;
if (prodActiveTab === '非成品') {
sel.innerHTML = '<option value="">请选择</option><option value="包材">包材</option><option value="其他">其他</option>';
} else {
sel.innerHTML = '<option value="">请选择</option><option value="成品">成品</option><option value="清库存">清库存</option><option value="现货">现货</option><option value="赠品">赠品</option><option value="原材料">原材料</option>';
}
}
function closeProductAddModal() {
var modal = document.getElementById('prodAddModal');
if (modal) modal.style.display = 'none';
var dd = document.getElementById('pa_supDropdown');
if (dd) dd.style.display = 'none';
var fd = document.getElementById('pa_folDropdown');
if (fd) fd.style.display = 'none';
}
function submitProductAdd() {
var data = {
product_code: document.getElementById('pa_code').value.trim(),
product_name: document.getElementById('pa_name').value.trim(),
brand: document.getElementById('pa_brand').value.trim(),
product_type: document.getElementById('pa_type').value,
lead_time: document.getElementById('pa_lead').value,
logistics_time: document.getElementById('pa_logis').value,
supplier_code: document.getElementById('pa_supcode').value.trim(),
supplier_name: document.getElementById('pa_supname').value.trim(),
procurement_follower: document.getElementById('pa_follower').value.trim(),
procurement_planner: document.getElementById('pa_planner').value.trim(),
operator: document.getElementById('pa_operator').value.trim(),
developer: document.getElementById('pa_developer').value.trim(),
platform: document.getElementById('pa_platform').value.trim(),
created_at: document.getElementById('pa_created')?.value || null
};
if (!data.product_code || !data.product_name) {
alert('货品编号和名称不能为空');
return;
}
if (!data.brand) { alert('品牌不能为空'); return; }
if (!data.product_type) { alert('货品类型不能为空'); return; }
if (!data.supplier_name) { alert('供应商名称不能为空'); return; }
if (!data.lead_time) { alert('货期不能为空'); return; }
if (!data.logistics_time) { alert('物流时间不能为空'); return; }
if (!data.procurement_follower) { alert('采购跟单不能为空'); return; }
window.saveFollowerHistory(data.procurement_follower);
if (!data.procurement_planner) { alert('采购计划不能为空'); return; }
fetch('/api/product/create', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
var newCode = data.product_code;
closeProductAddModal();
loadProducts(1);
openBomBindModal(newCode);
} else {
alert('❌ ' + (res.msg || '创建失败'));
}
})
.catch(function() { alert('❌ 网络错误'); });
}
// ─── 新建成品BOM绑定弹窗 ───
// ─── 生成单行BOM绑定行 ───
window._bomBindRowHtml = function(idx) {
var html = '<tr class="bom-bind-row">';
html += '<td>' + idx + '</td>';
html += '<td><input class="form-input bom-mat-code" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px" placeholder="必填" autocomplete="off"></td>';
html += '<td><input class="form-input bom-mat-name" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px" placeholder="必填" autocomplete="off"></td>';
html += '<td><input class="form-input bom-mat-type" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px;background:#f5f5f5;color:#999;text-align:center" value="包材" readonly></td>';
html += '<td><input class="form-input bom-sup-code" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px;background:#f5f5f5;color:#999" readonly placeholder="自动匹配"></td>';
html += '<td style="position:relative"><input class="form-input bom-sup-name" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px" placeholder="输入关键词" autocomplete="off">';
html += '<div class="bom-sup-dd" style="position:absolute;top:100%;left:0;right:0;z-index:100;background:#fff;border:1px solid #d9d9d9;border-radius:4px;max-height:160px;overflow-y:auto;display:none;box-shadow:0 4px 12px rgba(0,0,0,0.12)"></div></td>';
html += '<td style="width:65px;text-align:center"><input class="form-input bom-mat-qty" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px;text-align:center" type="number" value="1" min="0.01" step="0.01" autocomplete="off"></td>';
html += '<td style="width:30px;text-align:center"><span style="cursor:pointer;color:#999;font-size:14px" onclick="removeBomBindRow(this)">✕</span></td>';
html += '</tr>';
return html;
};
window.openBomBindModal = function(productCode) {
if (!productCode) return;
var overlay = document.createElement('div');
overlay.id = 'bomBindModal';
overlay.className = 'modal-overlay';
overlay.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:99999;align-items:center;justify-content:center';
overlay.onclick = function(e) { if (e.target === overlay) {} };
overlay.innerHTML = [
'<div class="modal" style="width:95vw;max-width:1100px">',
'<div class="modal-header">',
'<h3>📦 绑定包材 BOM</h3>',
'<span style="font-size:13px;color:#666">为成品 <strong>' + escHtml(productCode) + '</strong> 绑定包材</span>',
'<span class="modal-close" onclick="confirmCloseBomBind(\'' + escJs(productCode) + '\')" style="margin-left:auto;font-size:22px;cursor:pointer;color:#999">&times;</span>',
'</div>',
'<div class="modal-body" style="padding:10px 16px;overflow:auto;max-height:65vh">',
'<table class="data-table" style="width:100%;font-size:12px">',
'<thead><tr><th style="width:30px">#</th><th>货品编号</th><th>货品名称</th><th style="width:60px">货品类型</th><th>供应商编码</th><th>供应商名称</th><th style="width:60px;text-align:center">用量</th><th style="width:30px"></th></tr></thead>',
'<tbody id="bomBindTbody">' + window._bomBindRowHtml(1) + '</tbody>',
'</table>',
'<div style="margin-top:6px"><button class="btn btn-sm" onclick="addBomBindRow()">+ 添加一行</button></div>',
'</div>',
'<div class="modal-footer" style="display:flex;justify-content:space-between">',
'<button class="btn" onclick="confirmCloseBomBind(\'' + escJs(productCode) + '\')">跳过</button>',
'<button class="btn btn-primary" onclick="submitBomBind(\'' + escJs(productCode) + '\')">✅ 确认绑定</button>',
'</div>',
'</div>'
].join('');
document.body.appendChild(overlay);
// 第一行自动绑定供应商搜索
window._bindBomSupEvents(document.querySelectorAll('#bomBindTbody .bom-sup-name'));
};
window._bindBomSupEvents = function(els) {
[].forEach.call(els, function(el) {
el.removeEventListener('input', window._bomSupInputHandler);
el.removeEventListener('focus', window._bomSupFocusHandler);
el.addEventListener('input', window._bomSupInputHandler);
el.addEventListener('focus', window._bomSupFocusHandler);
});
};
window._bomSupInputHandler = function() {
var dd = this.parentElement.querySelector('.bom-sup-dd');
if (!dd) return;
var val = this.value.trim();
if (!val || !window._supplierList) { dd.style.display = 'none'; return; }
var v = val.toLowerCase();
var matched = window._supplierList.filter(function(s) { return (s.supplier_name || '').toLowerCase().indexOf(v) >= 0; });
if (matched.length === 0 || matched.length > 50) { dd.style.display = 'none'; return; }
dd.innerHTML = '';
// fixed定位避免被overflow裁剪
var rect = this.getBoundingClientRect();
dd.style.position = 'fixed';
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = rect.width + 'px';
dd.style.display = 'block';
dd.style.zIndex = 99999;
matched.slice(0, 20).forEach(function(s) {
var item = document.createElement('div');
item.style.cssText = 'padding:8px 12px;cursor:pointer;font-size:13px;border-bottom:1px solid #f0f0f0;line-height:1.6';
item.className = 'bom-sup-dd-item';
item.dataset.code = s.supplier_code || '';
item.dataset.name = s.supplier_name || '';
item.innerHTML = '<span>' + escHtml(s.supplier_name) + '</span>' + (s.supplier_code ? ' <code style="font-size:11px;color:#999">' + escHtml(s.supplier_code) + '</code>' : '');
dd.appendChild(item);
});
// 滚动时隐藏
};
// 事件委托:点击供应商下拉选项
window.addEventListener('click', function(e) {
// BOM绑定弹窗供应商选择
var item = e.target.closest('.bom-sup-dd-item');
if (item) {
var row = item.closest('tr');
if (row) {
var supCode = row.querySelector('.bom-sup-code');
var supName = row.querySelector('.bom-sup-name');
if (supCode) supCode.value = item.dataset.code;
if (supName) supName.value = item.dataset.name;
var dd = row.querySelector('.bom-sup-dd');
if (dd) dd.style.display = 'none';
} else {
// 非表格行内(如新增弹窗成品供应商)
var parent = item.closest('[style*="position:relative"]');
if (parent) {
var inp = parent.querySelector('.bom-sup-name');
if (inp) inp.value = item.dataset.name;
var dd = parent.querySelector('.bom-sup-dd');
if (dd) dd.style.display = 'none';
}
}
return;
}
// 编辑弹窗供应商选择
var pe = e.target.closest('.pe-sup-dd-item');
if (pe) {
var nameInput = document.getElementById('pe_supplier_name_match');
var codeInput = document.getElementById('pe_supplier_code');
if (nameInput) nameInput.value = pe.dataset.name;
if (codeInput) codeInput.value = pe.dataset.code;
var dd = pe.closest('.pe-sup-dd');
if (dd) dd.style.display = 'none';
return;
}
});
window._bomSupFocusHandler = function() {
// focus时不操作,由input触发
};
window.addBomBindRow = function() {
var tbody = document.getElementById('bomBindTbody');
if (!tbody) return;
var idx = tbody.querySelectorAll('.bom-bind-row').length + 1;
tbody.insertAdjacentHTML('beforeend', window._bomBindRowHtml(idx));
window._bindBomSupEvents(tbody.querySelectorAll('.bom-bind-row:last-child .bom-sup-name'));
};
window.removeBomBindRow = function(el) {
var row = el.closest('tr');
if (row) row.remove();
var tbody = document.getElementById('bomBindTbody');
if (tbody) {
[].forEach.call(tbody.querySelectorAll('.bom-bind-row'), function(r, i) { r.cells[0].textContent = i + 1; });
}
};
window.submitBomBind = function(productCode) {
var tbody = document.getElementById('bomBindTbody');
if (!tbody) return;
var materials = [];
[].forEach.call(tbody.querySelectorAll('.bom-bind-row'), function(row) {
var code = (row.querySelector('.bom-mat-code')?.value || '').trim();
var name = (row.querySelector('.bom-mat-name')?.value || '').trim();
var supCode = (row.querySelector('.bom-sup-code')?.value || '').trim();
var supName = (row.querySelector('.bom-sup-name')?.value || '').trim();
var qty = parseFloat(row.querySelector('.bom-mat-qty')?.value) || 1;
if (code) { materials.push({material_code: code, material_name: name, quantity: qty, supplier_code: supCode, supplier_name: supName}); }
});
if (materials.length === 0) { alert('请填写至少一行物料信息'); return; }
var btn = document.querySelector('.btn-primary[onclick*="submitBomBind"]');
if (btn) { btn.disabled = true; btn.textContent = '绑定中...'; }
fetch('/api/bom/create_with_materials', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode, materials: materials})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (btn) { btn.disabled = false; btn.textContent = '✅ 确认绑定'; }
if (res.code === 0) {
alert('✅ ' + res.msg);
document.getElementById('bomBindModal').remove();
} else {
alert('❌ ' + (res.msg || '绑定失败'));
}
})
.catch(function() {
if (btn) { btn.disabled = false; btn.textContent = '✅ 确认绑定'; }
alert('❌ 网络错误');
});
};
// ─── 查看成品BOM弹窗 ───
window.viewProductBom = function(productCode) {
fetch('/api/product/detail_with_bom?code=' + encodeURIComponent(productCode))
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) { alert(res.msg || '获取BOM失败'); return; }
var materials = (res.data && res.data.bom_materials) || [];
var old = document.getElementById('viewBomModal');
if (old) old.remove();
var overlay = document.createElement('div');
overlay.id = 'viewBomModal';
overlay.className = 'modal-overlay';
overlay.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:99999;align-items:center;justify-content:center';
overlay.onclick = function(e) { if (e.target === overlay) {} };
var bodyHtml = '';
if (materials.length === 0) {
bodyHtml = '<tr><td colspan="5" style="text-align:center;padding:40px;color:#999">暂无绑定包材</td></tr>';
} else {
materials.forEach(function(m, i) {
bodyHtml += '<tr><td>' + (i + 1) + '</td><td><code>' + escHtml(m.material_code || '-') + '</code></td><td>' + escHtml(m.material_name || '-') + '</td><td style="text-align:center">' + m.bom_qty + '</td><td>' + escHtml(m.material_supplier || '-') + '</td></tr>';
});
}
overlay.innerHTML = [
'<div class="modal" style="width:600px">',
'<div class="modal-header"><h3>📋 包材清单</h3><code style="font-size:12px;color:#666">' + escHtml(productCode) + '</code><span class="modal-close" onclick="document.getElementById(\'viewBomModal\').remove()" style="margin-left:auto;font-size:22px;cursor:pointer;color:#999">✕</span></div>',
'<div class="modal-body" style="padding:12px 20px;max-height:60vh;overflow:auto">',
'<table class="data-table" style="width:100%;font-size:13px">',
'<thead><tr><th style="width:40px">#</th><th>物料编号</th><th>物料名称</th><th style="width:60px;text-align:center">用量</th><th>供应商</th></tr></thead>',
'<tbody>' + bodyHtml + '</tbody>',
'</table>',
'</div>',
'<div class="modal-footer" style="display:flex;justify-content:space-between">',
'<button class="btn" onclick="syncProductBom(\'' + escJs(productCode) + '\')" style="color:#722ed1;border-color:#722ed1">🔄 同步</button>',
'<button class="btn" onclick="document.getElementById(\'viewBomModal\').remove()">关闭</button>',
'</div>',
'</div>'
].join('');
document.body.appendChild(overlay);
});
};
// ─── 同步BOM数据到物料清单管理 ───
window.syncProductBom = function(productCode) {
if (!confirm('确认将 [' + productCode + '] 的包材数据同步到物料清单管理吗?')) return;
var btn = document.querySelector('.modal-footer .btn[onclick*="syncProductBom"]');
if (btn) { btn.disabled = true; btn.textContent = '同步中...'; }
fetch('/api/product/sync_bom', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (btn) { btn.disabled = false; btn.textContent = '🔄 同步'; }
if (res.code === 0) {
alert('✅ ' + res.msg);
// 刷新弹窗
document.getElementById('viewBomModal').remove();
window.viewProductBom(productCode);
} else {
alert('❌ ' + (res.msg || '同步失败'));
}
})
.catch(function() {
if (btn) { btn.disabled = false; btn.textContent = '🔄 同步'; }
alert('❌ 网络错误');
});
};
window.confirmCloseBomBind = function(productCode) {
var rows = document.getElementById('bomBindTbody');
var hasData = false;
if (rows) {
[].forEach.call(rows.querySelectorAll('.bom-bind-row input'), function(inp) {
if (inp.value.trim()) hasData = true;
});
}
if (hasData && !confirm('有未保存的物料信息,确定跳过吗?')) return;
document.getElementById('bomBindModal').remove();
};
// ─── 产品导出 ───
// ─── 产品导出 ───
function exportProducts() {
var type = prodActiveTab === '成品' ? '非包材' : '非成品专用';
// 关闭已有弹窗
var old = document.getElementById('prodExportModal');
if (old) old.remove();
var overlay = document.createElement('div');
overlay.id = 'prodExportModal';
overlay.className = 'modal-overlay';
overlay.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:99999;align-items:center;justify-content:center';
overlay.onclick = function(e) { if (e.target === overlay) {} };
// 导出可选字段
var exportFields = [
{field:'product_code', label:'货品编号'},
{field:'product_name', label:'货品名称'},
{field:'product_type', label:'货品类型'},
{field:'brand', label:'品牌'},
{field:'supplier_code', label:'供应商编码'},
{field:'supplier_name', label:'供应商名称'},
{field:'lead_time', label:'货期(天)'},
{field:'logistics_time', label:'物流时间(天)'},
{field:'procurement_follower', label:'采购跟单'},
{field:'procurement_planner', label:'采购计划'},
{field:'platform', label:'平台'},
{field:'operator', label:'运营'},
{field:'developer', label:'开发专员'},
{field:'created_at', label:'创建时间'},
{field:'import_date', label:'导入日期'},
];
var checkboxesHtml = '';
exportFields.forEach(function(f) {
var checked = window.prodColVisible && window.prodColVisible[f.field] !== false ? 'checked' : '';
checkboxesHtml += '<label style="display:flex;align-items:center;gap:6px;padding:4px 0;font-size:13px;cursor:pointer">' +
'<input type="checkbox" class="export-field-cb" value="' + f.field + '" ' + checked + '> ' + f.label +
'</label>';
});
overlay.innerHTML = [
'<div class="modal" style="width:420px">',
'<div class="modal-header"><h3>📤 导出字段选择</h3><span class="modal-close" onclick="document.getElementById(\'prodExportModal\').remove()">&times;</span></div>',
'<div class="modal-body" style="max-height:60vh;overflow-y:auto;padding:12px 20px">',
'<div style="margin-bottom:8px">',
'<label style="font-size:12px;cursor:pointer"><input type="checkbox" id="export-cb-all" checked onchange="toggleExportAll(this.checked)"> <strong>全选</strong></label>',
'</div>',
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:2px 16px">' + checkboxesHtml + '</div>',
'</div>',
'<div class="modal-footer" style="display:flex;justify-content:flex-end;gap:8px;padding:10px 20px">',
'<button class="btn" onclick="document.getElementById(\'prodExportModal\').remove()">取消</button>',
'<button class="btn btn-primary" onclick="confirmExportProducts()">确认导出</button>',
'</div>',
'</div>'
].join('');
document.body.appendChild(overlay);
}
window.toggleExportAll = function(checked) {
document.querySelectorAll('.export-field-cb').forEach(function(cb) { cb.checked = checked; });
};
window.confirmExportProducts = function() {
var type = prodActiveTab === '成品' ? '非包材' : '非成品专用';
var selected = [];
document.querySelectorAll('.export-field-cb:checked').forEach(function(cb) {
selected.push(cb.value);
});
if (selected.length === 0) {
alert('请至少选择一个字段');
return;
}
// 关闭弹窗
var overlay = document.getElementById('prodExportModal');
if (overlay) overlay.remove();
var url = '/api/products/export?product_type=' + encodeURIComponent(type) + '&fields=' + encodeURIComponent(selected.join(','));
window.open(url, '_blank');
};
// ─── 产品导入 ───
function openProductImportModal() {
var modal = document.getElementById('prodImportModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'prodImportModal';
modal.className = 'modal-overlay';
modal.style.display = 'flex';
modal.innerHTML = `
<div class="modal" style="width:420px">
<div class="modal-header">
<h3>导入产品</h3>
<span class="modal-close" onclick="closeProductImportModal()">&times;</span>
</div>
<div class="modal-body" style="padding:20px;text-align:center">
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:16px">
请先下载模板填写数据,再上传CSV文件导入
</p>
<a class="btn" href="/api/products/template" target="_blank" style="display:inline-block;margin-bottom:16px">📥 下载导入模板</a>
<div style="border:2px dashed var(--border,#e8e8e8);border-radius:8px;padding:20px;cursor:pointer;transition:border-color .2s"
onmouseenter="this.style.borderColor='var(--primary)'" onmouseleave="this.style.borderColor='var(--border,#e8e8e8)'"
onclick="document.getElementById('prodImportFile').click()">
<div style="font-size:36px;margin-bottom:8px">📂</div>
<div style="font-size:14px;color:var(--text)">点击选择 CSV 文件</div>
<div style="font-size:12px;color:var(--text-light);margin-top:4px">或拖拽文件到此区域</div>
</div>
<input type="file" id="prodImportFile" accept=".csv" style="display:none" onchange="previewProductImportFile()">
<div id="prodImportPreview" style="margin-top:12px;font-size:13px;color:var(--text-secondary)"></div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeProductImportModal()">取消</button>
<button class="btn btn-primary" id="prodImportBtn" disabled onclick="submitProductImport()">开始导入</button>
</div>
</div>
`;
document.body.appendChild(modal);
} else {
modal.style.display = 'flex';
document.getElementById('prodImportPreview').textContent = '';
document.getElementById('prodImportBtn').disabled = true;
document.getElementById('prodImportFile').value = '';
}
}
function closeProductImportModal() {
var modal = document.getElementById('prodImportModal');
if (modal) modal.style.display = 'none';
}
function previewProductImportFile() {
var fileInput = document.getElementById('prodImportFile');
var preview = document.getElementById('prodImportPreview');
var btn = document.getElementById('prodImportBtn');
if (fileInput.files && fileInput.files.length > 0) {
preview.textContent = '已选择:' + fileInput.files[0].name + '(' + (fileInput.files[0].size / 1024).toFixed(1) + 'KB)';
btn.disabled = false;
} else {
preview.textContent = '';
btn.disabled = true;
}
}
function submitProductImport() {
var fileInput = document.getElementById('prodImportFile');
if (!fileInput.files || !fileInput.files[0]) return;
var formData = new FormData();
formData.append('file', fileInput.files[0]);
document.getElementById('prodImportBtn').disabled = true;
document.getElementById('prodImportBtn').textContent = '导入中...';
fetch('/api/products/import', {
method: 'POST',
body: formData
})
.then(function(r) { return r.json(); })
.then(function(res) {
alert(res.msg || (res.code === 0 ? '✅ 导入成功' : '❌ 导入失败'));
closeProductImportModal();
loadProducts(1);
})
.catch(function() { alert('❌ 网络错误'); })
.finally(function() {
document.getElementById('prodImportBtn').disabled = false;
document.getElementById('prodImportBtn').textContent = '开始导入';
});
}
// ─── 产品基础表 字段设置弹窗 ───
function renderProdColCheckboxes() {
const grid = document.getElementById('prodColGrid');
if (!grid) return;
grid.innerHTML = '';
const order = window._prodColDragOrder || PROD_COLUMNS.map(function(c) { return c.field; });
var tab = prodActiveTab;
var skipFields = ['brand','lead_time','logistics_time','procurement_follower','procurement_planner','platform','operator','developer','created_at'];
order.forEach(function(field) {
const col = PROD_COLUMNS.find(function(c) { return c.field === field; });
if (!col) return;
// 包材tab隐藏不可用字段
if (tab === '非成品' && skipFields.indexOf(field) >= 0) return;
const checked = window._prodColPendingVisible[field] !== false ? 'checked' : '';
const label = document.createElement('label');
label.draggable = true;
label.dataset.field = field;
label.style.cssText = 'padding:4px 6px;border-radius:4px;cursor:grab;user-select:none;display:flex;align-items:center;gap:4px';
label.innerHTML = '<span style="color:#bbb;font-size:12px;cursor:grab">⠿</span><input type="checkbox" id="prod-cb-' + field + '" ' + checked + ' onchange="toggleProdColPending(\'' + field + '\')"> ' + col.label;
label.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('text/plain', field);
this.style.opacity = '0.4';
});
label.addEventListener('dragend', function(e) {
this.style.opacity = '1';
document.querySelectorAll('#prodColGrid label').forEach(function(el) { el.style.border = ''; });
});
label.addEventListener('dragover', function(e) {
e.preventDefault();
document.querySelectorAll('#prodColGrid label').forEach(function(el) { el.style.border = ''; });
this.style.border = '2px dashed var(--primary)';
});
label.addEventListener('dragleave', function(e) {
this.style.border = '';
});
label.addEventListener('drop', function(e) {
e.preventDefault();
document.querySelectorAll('#prodColGrid label').forEach(function(el) { el.style.border = ''; });
const fromField = e.dataTransfer.getData('text/plain');
const toField = this.dataset.field;
if (fromField === toField) return;
const order = window._prodColDragOrder;
const fromIdx = order.indexOf(fromField);
const toIdx = order.indexOf(toField);
if (fromIdx === -1 || toIdx === -1) return;
order.splice(fromIdx, 1);
order.splice(toIdx, 0, fromField);
renderProdColCheckboxes();
});
grid.appendChild(label);
});
// 同步全选状态
const allCb = document.getElementById('prod-cb-all');
if (allCb) {
allCb.checked = PROD_COLUMNS.every(function(c) { return window._prodColPendingVisible[c.field] !== false; });
}
}
window.openProdColModal = function() {
window._prodColDragOrder = PROD_COLUMNS.map(function(c) { return c.field; });
const grid = document.getElementById('prodColGrid');
if (grid) renderProdColCheckboxes();
PROD_COLUMNS.forEach(function(c) {
window._prodColPendingVisible[c.field] = window.prodColVisible[c.field] !== false;
const cb = document.getElementById('prod-cb-' + c.field);
if (cb) cb.checked = window.prodColVisible[c.field] !== false;
});
const allCb = document.getElementById('prod-cb-all');
if (allCb) {
allCb.checked = PROD_COLUMNS.every(function(c) { return window.prodColVisible[c.field] !== false; });
}
const searchInput = document.getElementById('prodColSearchInput');
if (searchInput) {
searchInput.value = '';
}
filterProdColCheckboxes();
document.getElementById('prodColModal').style.display = 'flex';
};
window.confirmProdCol = function() {
// 应用拖拽后的新顺序
const newOrder = window._prodColDragOrder;
if (newOrder) {
const reordered = [];
newOrder.forEach(function(field) {
const col = PROD_COLUMNS.find(function(c) { return c.field === field; });
if (col) reordered.push(col);
});
PROD_COLUMNS.forEach(function(c) {
if (newOrder.indexOf(c.field) === -1) reordered.push(c);
});
PROD_COLUMNS.length = 0;
reordered.forEach(function(c) { PROD_COLUMNS.push(c); });
}
PROD_COLUMNS.forEach(function(c) {
window.prodColVisible[c.field] = window._prodColPendingVisible[c.field] !== false;
});
// 重建表头
let theadHtml = '<tr><th style="width:50px">序号</th>';
PROD_COLUMNS.forEach(function(c) {
const vis = window.prodColVisible[c.field] ? '' : ' style="display:none"';
const sortAttr = c.sortable ? ' data-sort="' + c.field + '" class="sortable"' : '';
theadHtml += '<th' + sortAttr + ' class="col-' + c.field + '"' + vis + '>' + c.label + '</th>';
});
theadHtml += '<th class="col-action" style="width:120px;min-width:120px;position:sticky;right:0;background:var(--bg,#fff);z-index:3;border-left:1px solid var(--border)">操作</th>';
theadHtml += '</tr>';
const thead = document.getElementById('prodThead');
if (thead) thead.innerHTML = theadHtml;
// 刷新数据
loadProducts(1);
document.getElementById('prodColModal').style.display = 'none';
};
window.closeProdColModal = function() {
document.getElementById('prodColModal').style.display = 'none';
};
window.resetProdCol = function() {
var defaultOrder = window._prodColDefaultOrder || PROD_COLUMNS.map(function(c) { return c.field; });
// 恢复拖拽顺序为默认
window._prodColDragOrder = defaultOrder.slice();
// 恢复拖拽UI
renderProdColCheckboxes();
// 恢复字段显隐为默认
PROD_COLUMNS.forEach(function(c) {
window._prodColPendingVisible[c.field] = c.defaultShow;
var cb = document.getElementById('prod-cb-' + c.field);
if (cb) cb.checked = c.defaultShow;
});
// 全选checkbox同步
var allCb = document.getElementById('prod-cb-all');
if (allCb) {
allCb.checked = PROD_COLUMNS.every(function(c) { return c.defaultShow; });
}
// 清空搜索框
var searchEl = document.getElementById('prodColSearchInput');
if (searchEl) {
searchEl.value = '';
window.filterProdColCheckboxes();
}
};
window.toggleProdColPending = function(field) {
window._prodColPendingVisible[field] = !window._prodColPendingVisible[field];
const allCb = document.getElementById('prod-cb-all');
if (allCb) {
allCb.checked = PROD_COLUMNS.every(function(c) { return window._prodColPendingVisible[c.field] !== false; });
}
};
window.toggleProdColPendingAll = function(checked) {
PROD_COLUMNS.forEach(function(c) {
window._prodColPendingVisible[c.field] = checked;
const cb = document.getElementById('prod-cb-' + c.field);
if (cb) cb.checked = checked;
});
};
window.filterProdColCheckboxes = function() {
const keyword = (document.getElementById('prodColSearchInput')?.value || '').trim();
const labels = document.querySelectorAll('#prodColCheckboxes label');
labels.forEach(function(label) {
if (!keyword) {
label.style.display = '';
} else {
const text = label.textContent || '';
label.style.display = text.includes(keyword) ? '' : 'none';
}
});
};
// ─── 仓库管理 ───
var WH_PAGE_SIZE = 15;
var WH_PAGE_SIZE_OPTIONS = [15, 20, 50, 100];
var whCurrentPage = 1;
function loadWarehousePage(container) {
container.innerHTML = `
<div class="page-header">
<h1>仓库信息管理</h1>
<p class="page-desc">仓库信息与分类</p>
</div>
<div style="display:flex;gap:20px;align-items:flex-start">
<div style="flex:1;min-width:0">
<div class="card">
<div class="card-header"><h3>仓库列表</h3><div style="margin-left:auto;display:flex;gap:6px"><button class="btn btn-sm" onclick="openWarehouseImportModal()">📥 导入</button></div></div>
<div class="card-body" style="padding:0;overflow-x:auto">
<table class="data-table" id="whTable">
<thead><tr><th>序号</th><th>仓库编号</th><th>仓库名称</th><th>仓库分类</th><th>备注</th><th class="col-action" style="width:120px;min-width:120px;position:sticky;right:0;background:var(--bg,#fff);z-index:3;border-left:1px solid var(--border)">操作</th></tr></thead>
<tbody id="whTbody"></tbody>
</table>
</div>
<div id="whPagination" class="card-footer" style="padding:8px 16px;border-top:1px solid var(--border);text-align:center"></div>
</div>
</div>
<div style="width:320px;flex-shrink:0">
<div class="card">
<div class="card-header"><h3>➕ 添加/编辑仓库</h3></div>
<div class="card-body">
<div class="form-group">
<label>仓库编号</label>
<input type="text" id="whCode" class="form-input" placeholder="如 25">
</div>
<div class="form-group">
<label>仓库名称</label>
<input type="text" id="whName" class="form-input" placeholder="如 25××仓库">
</div>
<div class="form-group">
<label>仓库分类</label>
<select id="whCategory" class="form-input">
<option value="内部">内部</option>
<option value="平台">平台</option>
<option value="分销委外仓">分销委外仓</option>
</select>
</div>
<div class="form-group">
<label>备注</label>
<input type="text" id="whRemark" class="form-input" placeholder="可选">
</div>
<button class="btn btn-primary" onclick="saveWarehouse()" style="width:100%;margin-top:8px">💾 保存仓库</button>
<div id="whSaveMsg" style="margin-top:8px;font-size:13px"></div>
</div>
</div>
</div>
</div>
`;
fetchWarehouses(1);
}
function fetchWarehouses(page) {
whCurrentPage = page || 1;
var url = '/api/warehouse/list?page=' + whCurrentPage + '&size=' + WH_PAGE_SIZE;
fetch(url)
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
var data = res.data || {};
var rows = data.rows || [];
var total = data.total || 0;
var offset = (whCurrentPage - 1) * WH_PAGE_SIZE;
var tbody = document.getElementById('whTbody');
tbody.innerHTML = rows.map(function(w, i) {
var code = escHtml(w.warehouse_code || '-');
return '<tr><td>' + (offset + i + 1) + '</td><td><code>' + code + '</code></td><td>' + escHtml(w.warehouse_name || '-') + '</td><td>' + escHtml(w.category || '-') + '</td><td>' + escHtml(w.remark || '-') + '</td>' +
'<td style="white-space:nowrap"><button class="btn btn-sm btn-outline" onclick="editWarehouse(this)" style="margin-right:4px">编辑</button><button class="btn btn-sm btn-outline-danger" onclick="deleteWarehouse(\'' + escJs(w.warehouse_code) + '\')" style="color:#f5222d;border-color:#f5222d">删除</button></td></tr>';
}).join('') || '<tr><td colspan="6" style="text-align:center;padding:40px;color:var(--text-light)">暂无数据</td></tr>';
var pel = document.getElementById('whPagination');
if (pel) {
var totalPages = Math.ceil(total / WH_PAGE_SIZE);
if (totalPages <= 1) {
pel.innerHTML = '';
} else {
var ph = '<div class="pagination-inner">';
ph += '<button class="btn btn-sm" onclick="fetchWarehouses(1)" ' + (whCurrentPage <= 1 ? 'disabled' : '') + '>&#9664;&#9664;</button>';
ph += '<button class="btn btn-sm" onclick="fetchWarehouses(' + (whCurrentPage - 1) + ')" ' + (whCurrentPage <= 1 ? 'disabled' : '') + '>&#9664;</button>';
var start = Math.max(1, whCurrentPage - 2);
var end = Math.min(totalPages, whCurrentPage + 2);
for (var i = start; i <= end; i++) {
ph += '<button class="btn btn-sm ' + (i === whCurrentPage ? 'btn-primary' : '') + '" onclick="fetchWarehouses(' + i + ')">' + i + '</button>';
}
ph += '<button class="btn btn-sm" onclick="fetchWarehouses(' + (whCurrentPage + 1) + ')" ' + (whCurrentPage >= totalPages ? 'disabled' : '') + '>&#9654;</button>';
ph += '<button class="btn btn-sm" onclick="fetchWarehouses(' + totalPages + ')" ' + (whCurrentPage >= totalPages ? 'disabled' : '') + '>&#9654;&#9654;</button>';
ph += '<span class="page-info" style="margin-left:8px">' + whCurrentPage + '/' + totalPages + '页 共' + total + '条</span>';
ph += '</div>';
pel.innerHTML = ph;
}
}
});
}
window.saveWarehouse = function() {
const code = document.getElementById('whCode').value.trim();
const name = document.getElementById('whName').value.trim();
const category = document.getElementById('whCategory').value;
const remark = (document.getElementById('whRemark')?.value || '').trim();
if (!code || !name) {
document.getElementById('whSaveMsg').innerHTML = '<span style="color:red">⚠️ 编号和名称不能为空</span>';
return;
}
const btn = document.querySelector('.btn-primary[onclick="saveWarehouse()"]');
btn.disabled = true;
btn.textContent = '保存中...';
fetch('/api/warehouse/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({warehouse_code: code, warehouse_name: name, category: category, remark: remark})
})
.then(r => r.json())
.then(res => {
btn.disabled = false;
btn.textContent = '💾 保存仓库';
if (res.code === 0) {
document.getElementById('whSaveMsg').innerHTML = '<span style="color:green">✅ ' + (res.msg || '保存成功') + '</span>';
document.getElementById('whCode').value = '';
document.getElementById('whName').value = '';
fetchWarehouses(1);
} else {
document.getElementById('whSaveMsg').innerHTML = '<span style="color:red">⚠️ ' + (res.msg || '保存失败') + '</span>';
}
}).catch(() => {
btn.disabled = false;
btn.textContent = '💾 保存仓库';
document.getElementById('whSaveMsg').innerHTML = '<span style="color:red">⚠️ 网络错误</span>';
});
}
window.editWarehouse = function(btn) {
// 从按钮所在行获取仓库数据
var row = btn.parentElement.parentElement;
var cells = row.cells;
if (cells.length < 5) return;
var code = cells[1].textContent.trim();
var name = cells[2].textContent.trim();
var category = cells[3].textContent.trim();
var remark = cells[4].textContent.trim();
// 移除旧弹窗
var oldModal = document.getElementById('editWarehouseModal');
if (oldModal) oldModal.remove();
// 创建弹窗
var modal = document.createElement('div');
modal.id = 'editWarehouseModal';
modal.className = 'modal-overlay';
modal.style.display = 'flex';
modal.innerHTML = '<div class="modal" style="width:480px">' +
'<div class="modal-header">' +
'<h3>编辑仓库</h3>' +
'<span class="modal-close" onclick="document.getElementById(\'editWarehouseModal\').style.display=\'none\'">&times;</span>' +
'</div>' +
'<div class="modal-body">' +
'<div class="form-group">' +
'<label>仓库编号</label>' +
'<input type="text" id="editWhCode" class="form-input" value="' + escHtml(code) + '" readonly>' +
'</div>' +
'<div class="form-group">' +
'<label>仓库名称</label>' +
'<input type="text" id="editWhName" class="form-input" value="' + escHtml(name) + '">' +
'</div>' +
'<div class="form-group">' +
'<label>仓库分类</label>' +
'<select id="editWhCategory" class="form-input">' +
'<option value="内部"' + (category === '内部' ? ' selected' : '') + '>内部</option>' +
'<option value="平台"' + (category === '平台' ? ' selected' : '') + '>平台</option>' +
'<option value="分销委外仓"' + (category === '分销委外仓' ? ' selected' : '') + '>分销委外仓</option>' +
'</select>' +
'</div>' +
'<div class="form-group">' +
'<label>备注</label>' +
'<input type="text" id="editWhRemark" class="form-input" value="' + escHtml(remark) + '">' +
'</div>' +
'<div id="editWhSaveMsg" style="margin-top:8px;font-size:13px;color:green"></div>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn" onclick="document.getElementById(\'editWarehouseModal\').style.display=\'none\'">取消</button>' +
'<button class="btn btn-primary" onclick="saveEditedWarehouse()">💾 保存</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
};
window.saveEditedWarehouse = function() {
var code = document.getElementById('editWhCode').value.trim();
var name = document.getElementById('editWhName').value.trim();
var category = document.getElementById('editWhCategory').value;
var remark = document.getElementById('editWhRemark').value.trim();
var msgEl = document.getElementById('editWhSaveMsg');
if (!code || !name) {
msgEl.innerHTML = '<span style="color:red">⚠️ 编号和名称不能为空</span>';
return;
}
fetch('/api/warehouse/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({warehouse_code: code, warehouse_name: name, category: category, remark: remark})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
msgEl.innerHTML = '<span style="color:green">✅ ' + (res.msg || '保存成功') + '</span>';
document.getElementById('editWarehouseModal').style.display = 'none';
fetchWarehouses(1);
} else {
msgEl.innerHTML = '<span style="color:red">⚠️ ' + (res.msg || '保存失败') + '</span>';
}
})
.catch(function() {
msgEl.innerHTML = '<span style="color:red">⚠️ 网络错误</span>';
});
};
window.deleteWarehouse = function(code) {
if (!confirm('确认删除仓库 ' + code + ' 吗?')) return;
fetch('/api/warehouse/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({warehouse_code: code})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
fetchWarehouses(1);
} else {
alert(res.msg || '删除失败');
}
});
};
// ─── 仓库导入 ───
window.openWarehouseImportModal = function() {
var old = document.getElementById('whImportModal');
if (old) old.remove();
var overlay = document.createElement('div');
overlay.id = 'whImportModal';
overlay.className = 'modal-overlay';
overlay.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:99999;align-items:center;justify-content:center';
overlay.onclick = function(e) { if (e.target === overlay) {} };
overlay.innerHTML = [
'<div class="modal" style="width:420px">',
'<div class="modal-header"><h3>导入仓库</h3><span class="modal-close" onclick="document.getElementById(\'whImportModal\').remove()">&times;</span></div>',
'<div class="modal-body" style="padding:20px;text-align:center">',
'<p style="font-size:13px;color:#888;margin-bottom:14px">请先下载模板填写数据,再上传CSV文件导入</p>',
'<a class="btn" href="/api/warehouse/export_template" target="_blank" style="display:inline-block;margin-bottom:16px">📥 下载导入模板</a>',
'<div style="border:2px dashed #e8e8e8;border-radius:8px;padding:20px;cursor:pointer"',
'onmouseenter="this.style.borderColor=\'var(--primary)\';" onmouseleave="this.style.borderColor=\'#e8e8e8\';"',
'onclick="document.getElementById(\'whImportFile\').click()">',
'<div style="font-size:36px;margin-bottom:8px">📂</div>',
'<div style="font-size:14px;color:#333">点击选择 CSV 文件</div>',
'</div>',
'<input type="file" id="whImportFile" accept=".csv" style="display:none" onchange="previewWhImportFile()">',
'<div id="whImportPreview" style="margin-top:10px;font-size:13px;color:#888"></div>',
'</div>',
'<div class="modal-footer">',
'<button class="btn" onclick="document.getElementById(\'whImportModal\').remove()">取消</button>',
'<button class="btn btn-primary" id="whImportBtn" disabled onclick="submitWhImport()">开始导入</button>',
'</div>',
'</div>'
].join('');
document.body.appendChild(overlay);
};
window.previewWhImportFile = function() {
var f = document.getElementById('whImportFile');
var p = document.getElementById('whImportPreview');
var b = document.getElementById('whImportBtn');
if (f.files && f.files.length > 0) {
p.textContent = '已选择:' + f.files[0].name + '(' + (f.files[0].size / 1024).toFixed(1) + 'KB)';
b.disabled = false;
} else {
p.textContent = '';
b.disabled = true;
}
};
window.submitWhImport = function() {
var f = document.getElementById('whImportFile');
if (!f.files || !f.files[0]) return;
var btn = document.getElementById('whImportBtn');
btn.disabled = true;
btn.textContent = '导入中...';
var formData = new FormData();
formData.append('file', f.files[0]);
fetch('/api/warehouse/import', {
method: 'POST',
body: formData
})
.then(function(r) { return r.json(); })
.then(function(res) {
btn.disabled = false;
btn.textContent = '开始导入';
if (res.code === 0) {
alert(res.msg);
document.getElementById('whImportModal').remove();
fetchWarehouses(1);
} else {
alert(res.msg || '导入失败');
}
})
.catch(function(err) {
btn.disabled = false;
btn.textContent = '开始导入';
alert('请求失败:' + err.message);
});
};
// ─── 库存信息(成品/包材) ───
let _invProductType = '';
function loadInventoryPage(container) {
// 检测当前页面是成品库存还是包材库存
const path = window.location.pathname;
let title, desc, type;
if (path.indexOf('/finished') >= 0) {
title = '成品库存';
desc = '成品库存明细';
type = '成品';
} else if (path.indexOf('/packaging') >= 0) {
title = '包材库存';
desc = '包材库存明细(瓶子、泵头、彩盒、内料等)';
type = '包材';
} else {
title = '库存信息';
desc = '各仓库库存明细';
type = '';
}
_invProductType = type;
container.innerHTML = `
<div class="page-header">
<h1>${title}</h1>
<p class="page-desc">${desc}</p>
</div>
<div class="card">
<div class="card-body" style="padding:12px 16px">
<div class="filter-bar" style="flex-wrap:wrap;gap:8px">
<select id="invDate" class="form-input" style="width:130px"><option value="">全部日期</option></select>
<input type="text" id="invProdCode" class="form-input" style="width:110px" placeholder="货品编号">
<input type="text" id="invProdName" class="form-input" style="width:130px" placeholder="货品名称">
<input type="text" id="invWarehouse" class="form-input" style="width:120px" placeholder="仓库名称">
<button class="btn btn-primary" onclick="searchInventory()">查询</button>
<button class="btn" onclick="resetInventoryFilters()">重置</button>
<span class="data-count" id="invCount"></span>
</div>
</div>
</div>
<div class="card" style="margin-top:15px">
<div class="card-header"><h3>${title}明细</h3></div>
<div class="card-body" style="padding:0;overflow-x:auto">
<div class="card-header" style="display:flex;align-items:center;gap:8px;padding:8px 16px;border-bottom:1px solid var(--border)">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
<input type="checkbox" id="invSelectAll" onchange="invToggleAll(this.checked)"> 全选
</label>
<span style="flex:1"></span>
<select id="invExportFormat" class="form-input" style="width:70px;font-size:12px;height:28px">
<option value="xlsx">xlsx</option>
<option value="xls">xls</option>
<option value="csv">csv</option>
</select>
<button class="btn btn-sm btn-primary" onclick="invExportSelected()">📥 导出</button>
<span id="invSelectedCount" style="font-size:12px;color:var(--text-light)"></span>
</div>
<table class="data-table" id="invTable">
<thead style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)"><tr><th style="width:36px;position:sticky;top:0;z-index:4;background:var(--bg,#fff)"><input type="checkbox" id="invCbAll" onchange="invToggleAll(this.checked)"></th><th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">序号</th><th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">货品编号</th><th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">货品名称</th>${_invProductType === '成品' ? '<th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">品牌</th>' : ''}<th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">仓库</th><th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">可用库存</th><th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">生产在途</th><th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">采购在途</th><th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">采购跟单</th><th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">采购计划</th><th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">供应商</th><th style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)">库存日期</th><th class="col-action" style="width:80px;min-width:80px;position:sticky;top:0;right:0;background:var(--bg,#fff);z-index:5;border-left:1px solid var(--border)">操作</th></tr></thead>
<tbody id="invTbody"></tbody>
</table>
<div id="invPagination" class="pagination-controls" style="padding:8px 16px"></div>
</div>
</div>
`;
// 并行加载:筛选选项 + 表格数据,互不等待
var filtersPromise = fetch('/api/inventory/filters').then(function(r){return r.json();}).then(function(res){
if(res.code!==0)return;
var sel=document.getElementById('invDate');
(res.data.dates||[]).forEach(function(d){
var opt=document.createElement('option');
opt.value=d;opt.textContent=d;
sel.appendChild(opt);
});
if(sel.options.length>1) sel.selectedIndex=1;
}).catch(function(){});
// 直接加载数据,不等待筛选选项
loadInventoryData(1);
}
let invCurrentPage = 1;
let INV_PAGE_SIZE = 50;
function loadInventoryData(page) {
invCurrentPage = page;
const date = document.getElementById('invDate')?.value || '';
const code = document.getElementById('invProdCode')?.value || '';
const name = document.getElementById('invProdName')?.value || '';
const wh = document.getElementById('invWarehouse')?.value || '';
let url = '/api/inventory/list?page=' + page + '&size=' + INV_PAGE_SIZE;
if (date) url += '&date=' + encodeURIComponent(date);
if (code) url += '&prod_code=' + encodeURIComponent(code);
if (name) url += '&prod_name=' + encodeURIComponent(name);
if (wh) url += '&warehouse=' + encodeURIComponent(wh);
if (_invProductType) url += '&product_type=' + encodeURIComponent(_invProductType);
fetch(url)
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
const data = res.data;
const tbody = document.getElementById('invTbody');
if (!data.rows || data.rows.length === 0) {
var cols = _invProductType === '成品' ? 14 : 13;
tbody.innerHTML = '<tr><td colspan="' + cols + '" style="text-align:center;padding:40px;color:var(--text-light)">暂无数据</td></tr>';
} else {
const startIdx = (page - 1) * INV_PAGE_SIZE;
window._invRows = data.rows;
tbody.innerHTML = data.rows.map((r, i) => {
var code = r.product_code || '';
var brandCell = (_invProductType === '成品') ? '<td>' + escHtml(r.brand || '-') + '</td>' : '';
return '<tr><td style="text-align:center"><input type="checkbox" class="inv-row-cb" value="' + escHtml(code) + '" onchange="invUpdateCount()"></td><td>' + (startIdx + i + 1) + '</td><td><code>' + escHtml(code) + '</code></td><td>' + escHtml(r.product_name || '-') + '</td>' + brandCell + '<td>' + escHtml(r.warehouse_name || '-') + '</td><td>' + (r.available_qty || 0) + '</td><td>' + (r.production_in_transit || 0) + '</td><td>' + (r.purchase_in_transit || 0) + '</td><td>' + renderNameBadges(r['采购跟单']) + '</td><td>' + renderNameBadges(r['采购计划']) + '</td><td>' + escHtml(r['供应商'] || '-') + '</td><td>' + (r.data_date || '-') + '</td><td class="col-action" style="position:sticky;right:0;z-index:2;background:var(--bg,#fff);border-left:1px solid var(--border);white-space:nowrap"><button class="btn btn-sm btn-outline" onclick="showProductDetailEdit(\'' + escHtml(code) + '\')" title="编辑">编辑</button></td></tr>';
}).join('');
invUpdateCount();
}
document.getElementById('invCount').textContent = '共 ' + (data.total || 0) + ' 条';
const pel = document.getElementById('invPagination');
if (pel) renderPaginationWithSize(pel, page, data.total || 0, INV_PAGE_SIZE, 'loadInventoryData', 'INV_PAGE_SIZE');
});
}
window.searchInventory = function() { loadInventoryData(1); };
window.resetInventoryFilters = function() {
document.getElementById('invDate').selectedIndex = 0;
document.getElementById('invProdCode').value = '';
document.getElementById('invProdName').value = '';
document.getElementById('invWarehouse').value = '';
loadInventoryData(1);
};
// ─── 库存勾选/导出 ───
// 导出可选字段
var INV_EXPORT_FIELDS = [
{key:'product_code', label:'货品编号', def:true},
{key:'product_name', label:'货品名称', def:true},
{key:'warehouse_name', label:'仓库', def:true},
{key:'available_qty', label:'可用库存', def:true},
{key:'production_in_transit', label:'生产在途', def:true},
{key:'purchase_in_transit', label:'采购在途', def:true},
{key:'cost_price', label:'成本价', def:false},
{key:'brand', label:'品牌', def:false},
{key:'采购跟单', label:'采购跟单', def:false},
{key:'采购计划', label:'采购计划', def:false},
{key:'供应商', label:'供应商', def:false},
{key:'data_date', label:'库存日期', def:true}
];
if (!window._invExportSelectedFields) {
window._invExportSelectedFields = INV_EXPORT_FIELDS.filter(function(f) { return f.def; }).map(function(f) { return f.key; });
}
window.invOpenFieldSelector = function() {
var old = document.getElementById('invFieldModal');
if (old) old.remove();
var html = '<div style="padding:16px 20px">';
html += '<h3 style="font-size:15px;margin-bottom:12px">选择导出字段</h3>';
html += '<label style="display:flex;align-items:center;gap:6px;padding:6px 0;cursor:pointer;border-bottom:1px solid var(--border);margin-bottom:6px">';
html += '<input type="checkbox" id="invFieldCbAll" checked onchange="(function(c){document.querySelectorAll(\'.inv-field-cb\').forEach(function(cb){cb.checked=c.checked});})(this)"> <strong>全选</strong></label>';
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px 16px">';
INV_EXPORT_FIELDS.forEach(function(f) {
var checked = window._invExportSelectedFields.indexOf(f.key) >= 0;
html += '<label style="display:flex;align-items:center;gap:6px;padding:4px 0;cursor:pointer">';
html += '<input type="checkbox" class="inv-field-cb" value="' + f.key + '"' + (checked ? ' checked' : '') + '>';
html += f.label + '</label>';
});
html += '</div>';
html += '<div style="text-align:right;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">';
html += '<button class="btn btn-sm" onclick="document.getElementById(\'invFieldModal\').remove()">取消</button>';
html += '<button class="btn btn-sm btn-primary" onclick="invConfirmExportFields()" style="margin-left:8px">确定</button></div></div>';
var m = document.createElement('div');
m.id = 'invFieldModal';
m.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:9999;display:flex;align-items:center;justify-content:center';
m.innerHTML = '<div style="background:#fff;border-radius:12px;width:420px;max-width:90vw;box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:20px">' + html + '</div>';
document.body.appendChild(m);
};
window.invConfirmExportFields = function() {
var checked = [];
document.querySelectorAll('.inv-field-cb:checked').forEach(function(cb) { checked.push(cb.value); });
window._invExportSelectedFields = checked;
document.getElementById('invFieldModal').remove();
};
window.invToggleAll = function(checked) {
document.querySelectorAll('.inv-row-cb').forEach(function(cb) { cb.checked = checked; });
var allCb = document.getElementById('invCbAll');
var allBtn = document.getElementById('invSelectAll');
if (allCb) allCb.checked = checked;
if (allBtn) allBtn.checked = checked;
invUpdateCount();
};
window.invUpdateCount = function() {
var checked = document.querySelectorAll('.inv-row-cb:checked').length;
var el = document.getElementById('invSelectedCount');
if (el) el.textContent = checked > 0 ? '已选 ' + checked + ' 条' : '';
};
window.invExportSelected = function() {
var checked = [];
document.querySelectorAll('.inv-row-cb:checked').forEach(function(cb) { checked.push(cb.value); });
if (checked.length === 0) { alert('请先勾选要导出的数据'); return; }
var fmt = document.getElementById('invExportFormat')?.value || 'xlsx';
// 先弹出字段选择
var curFields = window._invExportSelectedFields || INV_EXPORT_FIELDS.filter(function(f) { return f.def; }).map(function(f) { return f.key; });
var html = '<div style="padding:20px 24px">';
html += '<h3 style="font-size:16px;margin-bottom:6px">选择导出字段</h3>';
html += '<p style="font-size:12px;color:var(--text-secondary);margin-bottom:12px">已选 ' + checked.length + ' 条数据,格式:' + fmt.toUpperCase() + '</p>';
html += '<div style="max-height:320px;overflow-y:auto;padding:4px 0">';
html += '<label style="display:flex;align-items:center;gap:6px;padding:6px 0;cursor:pointer;border-bottom:1px solid #f0f0f0;margin-bottom:6px">';
html += '<input type="checkbox" id="invExportFieldAll" checked onchange="(function(c){document.querySelectorAll(\'.inv-export-fc\').forEach(function(cb){cb.checked=c.checked})})(this)"> <strong>全选</strong></label>';
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:2px 16px">';
INV_EXPORT_FIELDS.forEach(function(f) {
var checked2 = curFields.indexOf(f.key) >= 0;
html += '<label style="display:flex;align-items:center;gap:6px;padding:5px 0;cursor:pointer">';
html += '<input type="checkbox" class="inv-export-fc" value="' + f.key + '"' + (checked2 ? ' checked' : '') + ' onchange="(function(){var all=document.getElementById(\'invExportFieldAll\');if(all){var cbs=document.querySelectorAll(\'.inv-export-fc\');all.checked=Array.from(cbs).every(function(cb){return cb.checked})}})()">';
html += f.label + '</label>';
});
html += '</div></div>';
html += '<div style="text-align:right;margin-top:14px;padding-top:14px;border-top:1px solid var(--border)">';
html += '<button class="btn" onclick="document.getElementById(\'invExportModal\').remove()">取消</button>';
html += '<button class="btn btn-primary" id="invExportDoBtn" style="margin-left:8px">确认导出</button></div></div>';
var old = document.getElementById('invExportModal');
if (old) old.remove();
var m = document.createElement('div');
m.id = 'invExportModal';
m.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:9999;display:flex;align-items:center;justify-content:center';
m.innerHTML = '<div style="background:#fff;border-radius:12px;width:460px;max-width:90vw;box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:20px">' + html + '</div>';
document.body.appendChild(m);
// 绑定确认导出
document.getElementById('invExportDoBtn').onclick = function() {
var selFields = [];
document.querySelectorAll('.inv-export-fc:checked').forEach(function(cb) { selFields.push(cb.value); });
if (selFields.length === 0) { alert('请至少选择一个字段'); return; }
window._invExportSelectedFields = selFields;
document.getElementById('invExportModal').remove();
// 执行导出
var date = document.getElementById('invDate')?.value || '';
var type = window._invProductType || '';
fetch('/api/inventory/export', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_codes: checked, format: fmt, date: date, product_type: type, fields: selFields})
})
.then(function(r) {
if (!r.ok) return r.json().then(function(d) { throw new Error(d.msg || '导出失败'); });
var filename = 'inventory_export.' + fmt;
return r.blob().then(function(blob) {
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
});
})
.catch(function(e) { alert('❌ ' + e.message); });
};
};
// ─── 供应商管理 ───
function loadSuppliersPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>供应商管理</h1>
<p class="page-desc">供应商信息管理</p>
</div>
<div class="card">
<div class="card-body" style="padding:12px 16px">
<div class="filter-bar" style="flex-wrap:wrap;gap:8px">
<input type="text" id="supCode" class="form-input" style="width:120px" placeholder="供应商编码" onkeydown="if(event.key==='Enter')searchSuppliers()">
<input type="text" id="supName" class="form-input" style="width:150px" placeholder="供应商名称" onkeydown="if(event.key==='Enter')searchSuppliers()">
<button class="btn btn-primary" onclick="searchSuppliers()">查询</button>
<button class="btn" onclick="resetSupplierFilters()">重置</button>
<span class="data-count" id="supCount"></span>
</div>
</div>
</div>
<div class="card" style="margin-top:15px">
<div class="card-header"><h3>供应商列表</h3></div>
<div class="card-body" style="padding:0;overflow-x:auto">
<table class="data-table" id="supTable">
<thead style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)"><tr><th>序号</th><th>供应商编码</th><th>供应商名称</th><th>做货类型</th><th>简称</th><th>供应商分组</th><th>审核人</th><th>审核日期</th><th>数据状态</th><th>禁用状态</th><th class="col-action" style="width:160px;min-width:160px;position:sticky;right:0;background:var(--bg,#fff);z-index:3;border-left:1px solid var(--border)">操作</th></tr></thead>
<tbody id="supTbody"></tbody>
</table>
<div id="supPagination" class="pagination-controls" style="padding:8px 16px"></div>
</div>
</div>
`;
// 清除筛选框搜索记忆
['supCode','supName'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
loadSuppliers(1);
}
let supCurrentPage = 1;
let SUP_PAGE_SIZE = 50;
function loadSuppliers(page) {
supCurrentPage = page;
const code = (document.getElementById('supCode')?.value || '').trim();
const name = (document.getElementById('supName')?.value || '').trim();
let url = '/api/suppliers/list?page=' + page + '&size=' + SUP_PAGE_SIZE;
if (code) url += '&code=' + encodeURIComponent(code);
if (name) url += '&name=' + encodeURIComponent(name);
fetch(url)
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
const data = res.data;
const tbody = document.getElementById('supTbody');
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:40px;color:var(--text-light)">暂无数据</td></tr>';
} else {
const startIdx = (page - 1) * SUP_PAGE_SIZE;
tbody.innerHTML = data.rows.map((r, i) => {
var supCode = r.supplier_code || '';
var supName = r.supplier_name || '';
var html = '<tr><td>' + (startIdx + i + 1) + '</td>';
html += '<td><code>' + escHtml(supCode) + '</code></td>';
html += '<td>' + escHtml(supName) + '</td>';
html += '<td>' + escHtml(r.production_type || '-') + '</td>';
html += '<td>' + escHtml(r.short_name || '-') + '</td>';
html += '<td>' + escHtml(r.supplier_group || '-') + '</td>';
html += '<td>' + escHtml(r.reviewer || '-') + '</td>';
html += '<td>' + (r.review_date ? r.review_date : '-') + '</td>';
html += '<td>' + escHtml(r.data_status || '-') + '</td>';
html += '<td>' + escHtml(r.disabled_status || '-') + '</td>';
html += '<td class="col-action" style="position:sticky;right:0;z-index:2;background:var(--bg,#fff);border-left:1px solid var(--border);white-space:nowrap">';
html += '<button class="btn btn-sm btn-outline" onclick="editSupplier(\'' + escHtml(supCode) + '\')" title="编辑">编辑</button>';
html += '<button class="btn btn-sm btn-outline-danger" onclick="deleteSupplier(\'' + escJs(supCode) + '\')" style="margin-left:4px;color:#f5222d;border-color:#f5222d">删除</button>';
html += '</td></tr>';
return html;
}).join('');
}
document.getElementById('supCount').textContent = '共 ' + (data.total || 0) + ' 条';
const pel = document.getElementById('supPagination');
if (pel) renderPaginationWithSize(pel, page, data.total || 0, SUP_PAGE_SIZE, 'loadSuppliers', 'SUP_PAGE_SIZE');
});
}
window.searchSuppliers = function() { loadSuppliers(1); };
window.resetSupplierFilters = function() {
document.getElementById('supCode').value = '';
document.getElementById('supName').value = '';
loadSuppliers(1);
};
// ─── 用户管理 ───
function downloadUserTemplate() {
fetch('/api/users/template', {credentials: 'same-origin'})
.then(r => {
if (!r.ok) throw new Error('下载失败');
return r.blob();
})
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '用户导入模板.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
})
.catch(e => alert(e.message));
}
function handleImportUsers() {
const input = document.getElementById('userImportFile');
const file = input.files[0];
if (!file) { alert('请先选择CSV文件'); return; }
const btn = document.getElementById('importBtn');
if (btn) { btn.disabled = true; btn.textContent = '导入中...'; }
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = function(e) {
const bytes = new Uint8Array(e.target.result);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
const base64 = btoa(binary);
fetch('/api/users/import', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({csv_base64: base64})
})
.then(r => {
if (!r.ok) throw new Error('请求失败(' + r.status + ')');
return r.json();
})
.then(res => {
if (res.code !== 0) { alert(res.msg); return; }
let msg = res.msg;
if (res.data.errors && res.data.errors.length > 0) {
msg += '\n\n错误详情:\n' + res.data.errors.join('\n');
}
alert(msg);
loadUsers();
})
.catch(err => alert('导入失败:' + err.message))
.finally(() => {
if (btn) { btn.disabled = false; btn.textContent = '导入用户'; }
input.value = '';
});
};
reader.readAsText(file, 'UTF-8');
}
function loadUsersPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>用户管理</h1>
<p class="page-desc">管理系统用户账号与权限</p>
</div>
<!-- 权限角色说明卡 -->
<div class="card" style="margin-bottom:15px">
<div class="card-body" style="padding:12px 16px">
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;font-size:13px">
<span style="font-weight:600;color:var(--text-secondary)">🔐 权限角色:</span>
<span class="tag tag-red" style="cursor:default">超级管理员</span> 全部权限
<span class="tag tag-orange" style="cursor:default">管理员</span> 管理权限
<span class="tag tag-green" style="cursor:default">查看者</span> 只读权限
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>用户列表</h3>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn" onclick="downloadUserTemplate()">下载模板</button>
<input type="file" id="userImportFile" accept=".csv" style="display:none" onchange="handleImportUsers()">
<button class="btn" id="importBtn" onclick="document.getElementById('userImportFile').click()">导入用户</button>
<button class="btn btn-outline" onclick="openPermissionConfig()">🔐 角色权限配置</button>
<button class="btn btn-primary" onclick="showUserModal()">+ 新增用户</button>
</div>
</div>
<!-- 搜索栏 -->
<div style="display:flex;gap:10px;padding:12px 16px;background:#fafafa;border-bottom:1px solid var(--border-color);flex-wrap:wrap;align-items:center">
<input type="text" id="userSearchInput" placeholder="🔍 姓名/岗位" class="form-input" style="width:200px" oninput="userFilterChange()" autocomplete="off">
<select id="userRoleFilter" class="form-input" style="width:120px" onchange="userFilterChange()">
<option value="">全部角色</option>
<option value="super_admin">超级管理员</option>
<option value="admin">管理员</option>
<option value="viewer">查看者</option>
</select>
<select id="userStatusFilter" class="form-input" style="width:110px" onchange="userFilterChange()">
<option value="">全部状态</option>
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
<button class="btn btn-sm" onclick="clearUserFilter()">清除筛选</button>
</div>
<div class="card-body" style="padding:0;overflow-x:auto">
<table class="data-table" id="userTable">
<thead>
<tr>
<th>序号</th><th>用户名</th><th>姓名</th><th onclick="toggleRoleSort()" style="cursor:pointer;user-select:none">角色 <span id="roleSortIcon">↕</span></th><th>级别</th><th>岗位</th>
<th>手机</th><th>邮箱</th><th>状态</th><th>最后登录</th><th>操作</th>
</tr>
</thead>
<tbody id="userTbody"></tbody>
</table>
<div id="userPagination" class="pagination-controls" style="padding:8px 16px;text-align:center"></div>
</div>
</div>
<!-- 角色权限配置弹窗 -->
<div class="modal-overlay" id="permModal" style="display:none">
<div class="modal" style="width:800px">
<div class="modal-header">
<h3>🔐 角色权限配置</h3>
<span class="modal-close" onclick="closePermModal()">&times;</span>
</div>
<div class="modal-body" style="max-height:65vh;overflow-y:auto;padding:16px 20px">
<p style="color:var(--text-light);font-size:13px;margin-bottom:12px">勾选每个角色可以看到的页面。管理员始终拥有全部权限。</p>
<div id="permTableWrap" style="overflow-x:auto">
<table class="data-table" id="permTable" style="min-width:600px">
<thead id="permThead"><tr><th>页面</th><th class="col-action" style="width:120px;min-width:120px;position:sticky;right:0;background:var(--bg,#fff);z-index:3;border-left:1px solid var(--border)">操作</th></tr></thead>
<tbody id="permTbody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closePermModal()">取消</button>
<button class="btn btn-primary" onclick="savePermissions()">💾 保存配置</button>
</div>
</div>
</div>
<!-- 用户编辑弹窗 -->
<div class="modal-overlay" id="userModal" style="display:none">
<div class="modal" style="width:580px">
<div class="modal-header">
<h3 id="modalTitle">新增用户</h3>
<span class="modal-close" onclick="closeUserModal()">&times;</span>
</div>
<div class="modal-body">
<input type="hidden" id="editUserId">
<div class="form-row">
<div class="form-group" style="flex:1">
<label>用户名 <span style="color:red">*</span></label>
<input type="text" id="editUsername" class="form-input" placeholder="登录用户名" autocomplete="off">
</div>
<div class="form-group" style="flex:1">
<label>密码 <small id="pwHint">(必填)</small></label>
<input type="password" id="editPassword" class="form-input" placeholder="密码" autocomplete="new-password">
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>真实姓名</label>
<input type="text" id="editRealName" class="form-input" placeholder="真实姓名">
</div>
<div class="form-group" style="flex:1">
<label>岗位</label>
<select id="editPosition" class="form-input">
<option value="">请选择</option>
<option value="procurement_planner">📋 采购计划</option>
<option value="procurement_follower">📦 采购跟单</option>
<option value="product_developer">🔧 产品开发</option>
<option value="operator">📊 运营专员</option>
<option value="bp">🤝 BP</option>
<option value="procurement_trainee">🌱 采购管培生</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>角色权限</label>
<select id="editRole" class="form-input">
<option value="super_admin">🔴 超级管理员</option>
<option value="admin">🟠 管理员</option>
<option value="viewer">🟢 查看者</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label>员工级别</label>
<select id="editUserLevel" class="form-input">
<option value="employee">👤 员工</option>
<option value="leader">👥 组长</option>
<option value="director">👑 主管</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>手机</label>
<input type="text" id="editPhone" class="form-input" placeholder="手机号">
</div>
<div class="form-group" style="flex:1">
<label>邮箱</label>
<input type="text" id="editEmail" class="form-input" placeholder="邮箱">
</div>
<div class="form-group" style="flex:0 0 100px">
<label>状态</label>
<select id="editStatus" class="form-input">
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeUserModal()">取消</button>
<button class="btn btn-primary" onclick="saveUser()">保存</button>
</div>
</div>
</div>
`;
loadUsers();
}
let userPageSize = 10;
let userCurrentPage = 1;
let userAllData = [];
let userRoleSortOrder = ''; // '' 'asc' 'desc'
function userFilterChange() {
userCurrentPage = 1;
loadUsers();
}
function clearUserFilter() {
document.getElementById('userSearchInput').value = '';
document.getElementById('userRoleFilter').value = '';
document.getElementById('userStatusFilter').value = '';
userCurrentPage = 1;
loadUsers();
}
function toggleRoleSort() {
if (userRoleSortOrder === '' || userRoleSortOrder === 'desc') {
userRoleSortOrder = 'asc';
} else {
userRoleSortOrder = 'desc';
}
document.getElementById('roleSortIcon').textContent = userRoleSortOrder === 'asc' ? '↑' : '↓';
userCurrentPage = 1;
loadUsers();
}
const ROLE_MAP = {
'super_admin':'超级管理员',
'admin':'管理员',
'viewer':'查看者'
};
const ROLE_COLOR = {
'super_admin':'tag-red',
'admin':'tag-orange',
'viewer':'tag-green'
};
const POSITION_MAP = {
'procurement_planner':'采购计划',
'procurement_follower':'采购跟单',
'product_developer':'产品开发',
'operator':'运营专员',
'bp':'BP',
'procurement_trainee':'采购管培生'
};
const LEVEL_MAP = {'employee':'员工','leader':'组长','director':'主管'};
const LEVEL_STYLE = {
'employee':'background:#e6f7ff;padding:2px 8px;border-radius:4px;border:1px solid #91d5ff;font-size:12px',
'leader':'background:#fff7e6;padding:2px 8px;border-radius:4px;border:1px solid #ffd591;font-size:12px;font-weight:600',
'director':'background:#fff1f0;padding:2px 8px;border-radius:4px;border:1px solid #ffa39e;font-size:12px;font-weight:600'
};
function loadUsers(page) {
page = page || 1;
var search = document.getElementById('userSearchInput') ? document.getElementById('userSearchInput').value : '';
var role = document.getElementById('userRoleFilter') ? document.getElementById('userRoleFilter').value : '';
var positionFilter = document.getElementById('userPositionFilter') ? document.getElementById('userPositionFilter').value : '';
var status = document.getElementById('userStatusFilter') ? document.getElementById('userStatusFilter').value : '';
var sort = userRoleSortOrder ? 'role_' + userRoleSortOrder : '';
var url = '/api/users/list?search=' + encodeURIComponent(search) + '&role=' + encodeURIComponent(role) + '&position=' + encodeURIComponent(positionFilter) + '&status=' + encodeURIComponent(status) + '&sort=' + encodeURIComponent(sort);
fetch(url)
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
userAllData = res.data || [];
var totalPages = Math.max(1, Math.ceil(userAllData.length / userPageSize));
if (page > totalPages) page = totalPages;
renderUserPage(page);
});
}
function renderUserPage(page) {
userCurrentPage = page;
const total = userAllData.length;
const totalPages = Math.max(1, Math.ceil(total / userPageSize));
const start = (page - 1) * userPageSize;
const end = Math.min(start + userPageSize, total);
const pageData = userAllData.slice(start, end);
const tbody = document.getElementById('userTbody');
tbody.innerHTML = '';
pageData.forEach((u, idx) => {
var level = u.user_level || 'employee';
tbody.innerHTML += `<tr>
<td><strong>DY${String(start + idx + 1).padStart(4,'0')}</strong></td>
<td>${u.username}</td>
<td>${u.real_name || '-'}</td>
<td><span class="tag ${ROLE_COLOR[u.role] || 'tag-green'}">${ROLE_MAP[u.role] || u.role}</span></td>
<td><span style="${LEVEL_STYLE[level]}">${LEVEL_MAP[level] || level}</span></td>
<td>${POSITION_MAP[u.position] || u.position || '-'}</td>
<td>${u.phone || '-'}</td>
<td>${u.email || '-'}</td>
<td>${u.status==1 ? '<span class="status-dot active"></span>启用' : '<span class="status-dot"></span>禁用'}</td>
<td>${u.last_login ? new Date(u.last_login).toLocaleDateString('zh-CN', {year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-') : '-'}</td>
<td>
<button class="btn btn-sm btn-outline" onclick="editUser(${u.id})">编辑</button>
${u.is_protected ? '<span class="tag tag-grey" style="cursor:default;opacity:0.6">受保护</span>' : '<button class="btn btn-sm btn-outline-danger" onclick="deleteUser(' + u.id + ',\'' + u.username + '\')">删除</button>'}
</td>
</tr>`;
});
const pagination = document.getElementById('userPagination');
if (!pagination) return;
if (totalPages <= 1) { pagination.innerHTML = ''; return; }
let html = '<div class="pagination-controls">';
if (page > 1) html += `<button class="btn btn-sm" onclick="renderUserPage(${page-1})">上一页</button>`;
html += `<span style="margin:0 12px;color:var(--text-light)">第 ${page}/${totalPages} 页,共 ${total} 条</span>`;
if (page < totalPages) html += `<button class="btn btn-sm" onclick="renderUserPage(${page+1})">下一页</button>`;
html += '</div>';
pagination.innerHTML = html;
}
window.showUserModal = function() {
document.getElementById('modalTitle').textContent = '新增用户';
document.getElementById('editUserId').value = '';
document.getElementById('editUsername').value = '';
document.getElementById('editPassword').value = '';
document.getElementById('editRealName').value = '';
document.getElementById('editRole').value = 'viewer';
document.getElementById('editPosition').value = '';
document.getElementById('editUserLevel').value = 'employee';
document.getElementById('editPhone').value = '';
document.getElementById('editEmail').value = '';
document.getElementById('editStatus').value = '1';
document.getElementById('pwHint').textContent = '(必填)';
document.getElementById('userModal').style.display = 'flex';
};
window.editUser = function(id) {
fetch('/api/users/list')
.then(r => r.json())
.then(res => {
const u = (res.data || []).find(x => x.id === id);
if (!u) return;
document.getElementById('modalTitle').textContent = '编辑用户';
document.getElementById('editUserId').value = u.id;
document.getElementById('editUsername').value = u.username;
document.getElementById('editPassword').value = '';
document.getElementById('editRealName').value = u.real_name || '';
document.getElementById('editRole').value = u.role || 'viewer';
document.getElementById('editUserLevel').value = u.user_level || 'employee';
document.getElementById('editPosition').value = u.position || '';
document.getElementById('editPhone').value = u.phone || '';
document.getElementById('editEmail').value = u.email || '';
document.getElementById('editStatus').value = u.status;
document.getElementById('pwHint').textContent = '(留空不修改)';
document.getElementById('userModal').style.display = 'flex';
});
};
window.closeUserModal = function() {
document.getElementById('userModal').style.display = 'none';
};
window.saveUser = function() {
const id = document.getElementById('editUserId').value;
const data = {
username: document.getElementById('editUsername').value,
password: document.getElementById('editPassword').value,
real_name: document.getElementById('editRealName').value,
position: document.getElementById('editPosition').value,
role: document.getElementById('editRole').value,
user_level: document.getElementById('editUserLevel').value,
phone: document.getElementById('editPhone').value,
email: document.getElementById('editEmail').value,
status: parseInt(document.getElementById('editStatus').value)
};
if (!id) data.id = 0;
else data.id = parseInt(id);
const url = id ? '/api/users/update' : '/api/users/create';
fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)})
.then(r => r.json())
.then(res => {
if (res.code === 0) {
closeUserModal();
loadUsers(userCurrentPage);
} else {
alert(res.msg);
}
});
};
window.deleteUser = function(id, username) {
if (!confirm(`确定删除用户 "${username}"?`)) return;
fetch('/api/users/delete', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({id})})
.then(r => r.json())
.then(res => {
if (res.code === 0) loadUsers(userCurrentPage);
else alert(res.msg);
});
};
// ─── 角色权限配置 ───
window.openPermissionConfig = function() {
document.getElementById('permModal').style.display = 'flex';
renderPermTable();
};
window.closePermModal = function() {
document.getElementById('permModal').style.display = 'none';
};
var _permPages = [];
var _permRoles = [];
var _permData = {}; // {role_page: 1|0}
function renderPermTable() {
// 加载页面列表和角色列表
Promise.all([
fetch('/api/permissions/pages').then(function(r) { return r.json(); }),
fetch('/api/permissions/roles').then(function(r) { return r.json(); }),
fetch('/api/permissions/list').then(function(r) { return r.json(); })
]).then(function(results) {
if (results[0].code !== 0 || results[1].code !== 0) return;
_permPages = results[0].data || [];
_permRoles = results[1].data || [];
var perms = results[2].data || [];
// 构建权限索引
_permData = {};
perms.forEach(function(p) {
_permData[p.role + '_' + p.page] = p.permission;
});
// 渲染表头
var thead = document.getElementById('permThead');
if (!thead) return;
var headHtml = '<tr><th style="position:sticky;left:0;background:var(--bg,#fff);z-index:1">页面</th>';
var roleColors = {'super_admin':'tag-red','admin':'tag-orange','viewer':'tag-green'};
var roleNames = {'super_admin':'超级管理员','admin':'管理员','viewer':'查看者'};
_permRoles.forEach(function(r) {
var colorClass = roleColors[r.id] || 'tag-grey';
if (r.id === 'admin') {
headHtml += '<th style="text-align:center"><span class="tag ' + colorClass + '" style="cursor:default">' + roleNames[r.id] + '</span><br><small style="color:#999;font-weight:400">全部权限</small></th>';
} else {
headHtml += '<th style="text-align:center"><span class="tag ' + colorClass + '" style="cursor:default">' + roleNames[r.id] + '</span></th>';
}
});
headHtml += '</tr>';
thead.innerHTML = headHtml;
// 按分组渲染行
var tbody = document.getElementById('permTbody');
if (!tbody) return;
var groups = {};
_permPages.forEach(function(p) {
if (!groups[p.group]) groups[p.group] = [];
groups[p.group].push(p);
});
var bodyHtml = '';
Object.keys(groups).forEach(function(group) {
// 分组标题行
bodyHtml += '<tr style="background:#fafafa"><td colspan="' + (_permRoles.length + 1) + '" style="font-weight:600;font-size:13px;color:var(--text-secondary);padding:6px 12px">' + group + '</td></tr>';
groups[group].forEach(function(p) {
bodyHtml += '<tr><td style="position:sticky;left:0;background:var(--bg,#fff)">' + p.name + '</td>';
_permRoles.forEach(function(r) {
var key = r.id + '_' + p.id;
var checked = (_permData[key] == 1) ? 'checked' : '';
var disabled = (r.id === 'admin') ? 'disabled' : '';
bodyHtml += '<td style="text-align:center"><input type="checkbox" class="perm-cb" data-role="' + r.id + '" data-page="' + p.id + '" ' + checked + ' ' + disabled + '></td>';
});
bodyHtml += '</tr>';
});
});
tbody.innerHTML = bodyHtml;
});
}
window.savePermissions = function() {
var items = [];
document.querySelectorAll('.perm-cb').forEach(function(cb) {
items.push({
role: cb.dataset.role,
page: cb.dataset.page,
permission: cb.checked ? 1 : 0
});
});
fetch('/api/permissions/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: items})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
alert('✅ 权限配置已保存');
closePermModal();
} else {
alert(res.msg || '保存失败');
}
})
.catch(function() { alert('保存失败'); });
};
// ─── 操作日志 ───
function loadLogsPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>操作日志</h1>
<p class="page-desc">系统操作记录</p>
</div>
<div class="card">
<div class="card-body" style="padding:12px 16px">
<div class="filter-bar" style="flex-wrap:wrap;gap:8px">
<input type="text" id="logUser" class="form-input" style="width:100px" placeholder="操作人">
<input type="text" id="logAction" class="form-input" style="width:120px" placeholder="操作类型">
<button class="btn btn-primary" onclick="loadLogs(1)">查询</button>
<button class="btn" onclick="resetLogFilters()">重置</button>
<span class="data-count" id="logCount"></span>
</div>
</div>
</div>
<div class="card" style="margin-top:15px">
<div class="card-header"><h3>日志列表</h3></div>
<div class="card-body" style="padding:0;overflow-x:auto">
<table class="data-table" id="logTable">
<thead><tr><th>时间</th><th>操作人</th><th>操作类型</th><th>详情</th><th>IP</th></tr></thead>
<tbody id="logTbody"></tbody>
</table>
<div id="logPagination" class="pagination-controls" style="padding:8px 16px"></div>
</div>
</div>
`;
loadLogs(1);
}
let logCurrentPage = 1;
let LOG_PAGE_SIZE = 30;
function loadLogs(page) {
logCurrentPage = page;
const user = document.getElementById('logUser')?.value || '';
const action = document.getElementById('logAction')?.value || '';
let url = '/api/logs/list?page=' + page + '&size=' + LOG_PAGE_SIZE;
if (user) url += '&user=' + encodeURIComponent(user);
if (action) url += '&action=' + encodeURIComponent(action);
fetch(url)
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
const data = res.data;
const tbody = document.getElementById('logTbody');
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-light)">暂无日志</td></tr>';
} else {
tbody.innerHTML = data.rows.map(r => {
return '<tr><td>' + (r.created_at || '-') + '</td><td>' + escHtml(r.real_name || '-') + '</td><td>' + escHtml(r.action || '-') + '</td><td>' + escHtml(r.detail || '-') + '</td><td>' + escHtml(r.ip_address || '-') + '</td></tr>';
}).join('');
}
document.getElementById('logCount').textContent = '共 ' + (data.total || 0) + ' 条';
const pel = document.getElementById('logPagination');
if (pel) renderPaginationWithSize(pel, page, data.total || 0, LOG_PAGE_SIZE, 'loadLogs', 'LOG_PAGE_SIZE');
});
}
window.resetLogFilters = function() {
document.getElementById('logUser').value = '';
document.getElementById('logAction').value = '';
loadLogs(1);
};
// ─── 访问记录(登录日志) ───
function loadLoginRecordsPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>访问记录</h1>
<p class="page-desc">用户登录系统记录</p>
</div>
<div class="card">
<div class="card-body" style="padding:12px 16px">
<div class="filter-bar" style="flex-wrap:wrap;gap:8px">
<input type="text" id="loginLogUser" class="form-input" style="width:130px" placeholder="搜索用户名/姓名">
<button class="btn btn-primary" onclick="loadLoginRecords(1)">查询</button>
<button class="btn" onclick="resetLoginLogFilters()">重置</button>
<button class="btn btn-outline" id="refreshLoginLogBtn" onclick="refreshLoginLog()">🔄 刷新</button>
<span class="data-count" id="loginLogCount"></span>
<span id="loginLogRefreshNotice" style="display:none;font-size:13px;padding:4px 10px;border-radius:4px;background:#f6ffed;border:1px solid #b7eb8f;color:#52c41a"></span>
</div>
</div>
</div>
<div class="card" style="margin-top:15px">
<div class="card-header"><h3>登录记录</h3></div>
<div class="card-body" style="padding:0;overflow-x:auto">
<table class="data-table" id="loginLogTable">
<thead><tr><th>时间</th><th>用户名</th><th>姓名</th><th>角色</th><th>IP地址</th></tr></thead>
<tbody id="loginLogTbody"></tbody>
</table>
<div id="loginLogPagination" class="pagination-controls" style="padding:8px 16px"></div>
</div>
</div>
`;
loadLoginRecords(1);
}
var loginLogCurrentPage = 1;
var LOGIN_LOG_PAGE_SIZE = 30;
function loadLoginRecords(page) {
loginLogCurrentPage = page;
var user = document.getElementById('loginLogUser')?.value || '';
var url = '/api/logs/login_list?page=' + page + '&size=' + LOGIN_LOG_PAGE_SIZE;
if (user) url += '&user=' + encodeURIComponent(user);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
var data = res.data;
var tbody = document.getElementById('loginLogTbody');
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-light)">暂无登录记录</td></tr>';
} else {
tbody.innerHTML = data.rows.map(function(r) {
var roleLabel = r.role || '-';
return '<tr><td>' + escHtml(r.created_at || '-') + '</td><td><code>' + escHtml(r.username || '-') + '</code></td><td>' + escHtml(r.real_name || '-') + '</td><td>' + escHtml(roleLabel) + '</td><td><code>' + escHtml(r.ip_address || '-') + '</code></td></tr>';
}).join('');
}
document.getElementById('loginLogCount').textContent = '共 ' + (data.total || 0) + ' 条';
var pel = document.getElementById('loginLogPagination');
if (pel) renderPaginationWithSize(pel, page, data.total || 0, LOGIN_LOG_PAGE_SIZE, 'loadLoginRecords', 'LOGIN_LOG_PAGE_SIZE');
})
.finally(function() {
// 延迟2秒恢复按钮状态,让用户感知刷新完成
var notice = document.getElementById('loginLogRefreshNotice');
if (notice) {
notice.innerHTML = '✅ 刷新成功 ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
notice.style.color = '#52c41a';
setTimeout(function() {
notice.style.display = 'none';
notice.style.color = '';
}, 2000);
}
setTimeout(function() {
var btn = document.getElementById('refreshLoginLogBtn');
if (btn) { btn.textContent = '🔄 刷新'; btn.disabled = false; }
}, 2000);
});
}
window.refreshLoginLog = function() {
var btn = document.getElementById('refreshLoginLogBtn');
if (btn) { btn.textContent = '⟳ 刷新中...'; btn.disabled = true; }
// 显示刷新提示
var notice = document.getElementById('loginLogRefreshNotice');
if (notice) {
notice.style.display = 'block';
notice.innerHTML = '⟳ 正在刷新数据...';
}
loadLoginRecords(loginLogCurrentPage || 1);
};
window.resetLoginLogFilters = function() {
document.getElementById('loginLogUser').value = '';
loadLoginRecords(1);
};
// ─── 参数配置 ───
// ─── 权限配置页面 ───
// ─── 权限配置 ───
function loadPermissionsPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>权限配置</h1>
<p class="page-desc">管理每个用户的菜单操作权限</p>
</div>
<div id="permissionsContent">
<div style="text-align:center;padding:60px 20px;color:var(--text-light,#999)">
<div style="font-size:48px;margin-bottom:16px">⏳</div>
<div>加载中...</div>
</div>
</div>`;
loadPermissionsUsers();
}
// 全局缓存
var _permUsers = [];
var _permPages = [];
var _permOps = [];
function loadPermissionsUsers() {
var el = document.getElementById('permissionsContent');
if (!el) return;
Promise.all([
fetch('/api/permissions/users').then(function(r) { return r.json(); }),
fetch('/api/permissions/pages').then(function(r) { return r.json(); }),
fetch('/api/permissions/operations').then(function(r) { return r.json(); })
])
.then(function(results) {
var usersRes = results[0];
var pagesRes = results[1];
var opsRes = results[2];
if (usersRes.code !== 0) {
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--danger)">❌ 加载失败</div>';
return;
}
_permUsers = usersRes.data || [];
_permPages = pagesRes.data || [];
_permOps = opsRes.data || [];
// 收集去重的岗位和级别列表用于筛选下拉
var posSet = {};
var levelSet = {};
_permUsers.forEach(function(u) {
if (u.position) posSet[u.position] = true;
if (u.user_level) levelSet[u.user_level] = true;
});
var posList = Object.keys(posSet).sort();
var levelList = Object.keys(levelSet).sort();
var positionNames = {
'bp': 'BP', 'procurement_planner': '采购计划', 'procurement_follower': '采购跟单',
'procurement_trainee': '采购管培生', '采购开发': '采购开发'
};
var levelNames = {'employee': '员工', 'leader': '主管', 'director': '总监'};
// 筛选栏
var filterHtml = '<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;align-items:center">';
filterHtml += '<div style="display:flex;gap:4px"><input type="text" id="permFilterName" class="form-input" placeholder="🔍 姓名/账号" style="width:160px;padding:6px 10px" onkeydown="if(event.key===\'Enter\')renderPermUserGrid()"><button class="btn btn-sm btn-primary" onclick="renderPermUserGrid()" style="padding:4px 10px">搜索</button></div>';
filterHtml += '<select id="permFilterPos" class="form-input" style="width:130px;padding:6px 8px" onchange="renderPermUserGrid()"><option value="">全部岗位</option>';
posList.forEach(function(p) {
var pn = positionNames[p] || p;
filterHtml += '<option value="' + escHtml(p) + '">' + escHtml(pn) + '</option>';
});
filterHtml += '</select>';
filterHtml += '<select id="permFilterLevel" class="form-input" style="width:110px;padding:6px 8px" onchange="renderPermUserGrid()"><option value="">全部级别</option>';
levelList.forEach(function(l) {
var ln = levelNames[l] || l;
filterHtml += '<option value="' + escHtml(l) + '">' + escHtml(ln) + '</option>';
});
filterHtml += '</select>';
filterHtml += '<span id="permFilterCount" style="font-size:13px;color:var(--text-light,#999)"></span>';
filterHtml += '</div>';
filterHtml += '<div id="permUserGrid"></div>';
el.innerHTML = filterHtml;
renderPermUserGrid();
})
.catch(function() {
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--danger)">❌ 加载失败</div>';
});
}
function renderPermUserGrid() {
var el = document.getElementById('permUserGrid');
if (!el) return;
var kw = (document.getElementById('permFilterName')?.value || '').trim().toLowerCase();
var posFilter = document.getElementById('permFilterPos')?.value || '';
var levelFilter = document.getElementById('permFilterLevel')?.value || '';
var filtered = _permUsers.filter(function(u) {
if (kw) {
if (!(u.real_name || '').toLowerCase().includes(kw) && !(u.username || '').toLowerCase().includes(kw)) return false;
}
if (posFilter && u.position !== posFilter) return false;
if (levelFilter && u.user_level !== levelFilter) return false;
return true;
});
var countEl = document.getElementById('permFilterCount');
if (countEl) countEl.textContent = filtered.length + ' / ' + _permUsers.length + ' 人';
var roleNames = {
'super_admin': '超级管理员', 'admin': '管理员',
'procurement_planner': '采购计划', 'procurement_follower': '采购跟单',
'product_developer': '产品开发', 'operator': '运营专员',
'viewer': '查看者', '查看者': '查看者'
};
var roleColors = {
'super_admin': '#f5222d', 'admin': '#1890ff',
'procurement_planner': '#722ed1', 'procurement_follower': '#13c2c2',
'product_developer': '#eb2f96', 'operator': '#fa8c16',
'viewer': '#8c8c8c', '查看者': '#8c8c8c'
};
var roleBadgeColors = {
'super_admin': '#fff1f0', 'admin': '#e6f7ff',
'procurement_planner': '#f9f0ff', 'procurement_follower': '#e6fffb',
'product_developer': '#fff0f6', 'operator': '#fff7e6',
'viewer': '#f5f5f5', '查看者': '#f5f5f5'
};
var roleTextColors = {
'super_admin': '#cf1322', 'admin': '#096dd9',
'procurement_planner': '#531dab', 'procurement_follower': '#08979c',
'product_developer': '#c41d7f', 'operator': '#d46b08',
'viewer': '#595959', '查看者': '#595959'
};
// 按角色分组:超级管理员/管理员 → 其他角色
var adminUsers = [];
var otherUsers = [];
filtered.forEach(function(u) {
if (u.role === 'super_admin' || u.role === 'admin') {
adminUsers.push(u);
} else {
otherUsers.push(u);
}
});
function renderUserSection(users, showTitle) {
var h = '';
if (showTitle && users.length > 0) {
h += '<div style="font-weight:600;font-size:14px;color:#333;padding:8px 4px 12px;display:flex;align-items:center;gap:6px"><span style="font-size:18px">👤</span> 管理员</div>';
} else if (users.length > 0) {
h += '<div style="font-weight:600;font-size:14px;color:#333;padding:8px 4px 12px;display:flex;align-items:center;gap:6px;margin-top:8px"><span style="font-size:18px">👥</span> 其他成员</div>';
}
h += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:10px">';
users.forEach(function(u) {
var initial = u.real_name ? u.real_name.charAt(0) : '?';
var roleName = roleNames[u.role] || u.role;
var badgeBg = roleBadgeColors[u.role] || '#f5f5f5';
var badgeText = roleTextColors[u.role] || '#595959';
var avatarBg = roleColors[u.role] || '#8c8c8c';
var permCount = Object.keys(u.permissions || {}).length;
var positionNames = {
'bp': 'BP', 'procurement_planner': '采购计划', 'procurement_follower': '采购跟单',
'procurement_trainee': '采购管培生', '采购开发': '采购开发'
};
var levelNames = {'employee': '员工', 'leader': '主管', 'director': '总监'};
var posName = positionNames[u.position] || u.position || '';
var levelName = levelNames[u.user_level] || u.user_level || '';
h += '<div onclick="openPermUserModal(' + u.id + ')" class="perm-user-card" style="cursor:pointer;display:flex;align-items:center;gap:14px;padding:14px 18px;background:#fff;border:1px solid #e8e8e8;border-radius:10px;transition:all .2s ease">';
// 头像
h += '<div style="width:44px;height:44px;border-radius:50%;background:' + avatarBg + ';display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:18px;flex-shrink:0;box-shadow:0 2px 6px rgba(0,0,0,0.1)">' + escHtml(initial) + '</div>';
// 信息
h += '<div style="flex:1;min-width:0">';
h += '<div style="font-weight:600;font-size:15px;color:#262626;display:flex;align-items:center;gap:8px">';
h += escHtml(u.real_name || u.username);
h += '<span style="display:inline-block;font-size:11px;padding:1px 8px;border-radius:10px;background:' + badgeBg + ';color:' + badgeText + ';font-weight:500;line-height:20px">' + escHtml(roleName) + '</span>';
if (levelName) {
h += '<span style="display:inline-block;font-size:10px;padding:0 6px;border-radius:8px;background:#fff7e6;color:#d46b08;font-weight:500;line-height:18px">' + levelName + '</span>';
}
h += '</div>';
var infoParts = ['账号: ' + escHtml(u.username)];
if (posName) infoParts.push('岗位: ' + escHtml(posName));
if (levelName) infoParts.push('级别: ' + levelName);
if (permCount > 0) infoParts.push('已配置 ' + permCount + ' 个页面');
h += '<div style="font-size:12px;color:#8c8c8c;margin-top:3px">' + infoParts.join(' · ') + '</div>';
h += '</div>';
h += '<span style="color:#d9d9d9;font-size:20px;flex-shrink:0">›</span>';
h += '</div>';
});
h += '</div>';
return h;
}
var html = '';
if (adminUsers.length > 0) {
html += renderUserSection(adminUsers, true);
html += '<div style="border:0;border-top:1px solid #f0f0f0;margin:16px 0 4px"></div>';
}
if (otherUsers.length > 0) {
html += renderUserSection(otherUsers, false);
}
html += '<style>';
html += '.perm-user-card:hover{border-color:#d9d9d9!important;box-shadow:0 4px 16px rgba(0,0,0,0.08);transform:translateY(-1px)}';
html += '.perm-user-card:active{transform:translateY(0);box-shadow:0 2px 8px rgba(0,0,0,0.06)}';
html += '</style>';
if (filtered.length === 0) {
html = '<div style="text-align:center;padding:60px;color:var(--text-light,#999)">无匹配用户</div>';
}
el.innerHTML = html;
}
// 打开用户权限弹窗
var _editingUserId = null;
var _rolePagePerms = {}; // {page: 1/0} - 角色默认的页面权限
window.openPermUserModal = function(userId) {
_editingUserId = userId;
var user = null;
_permUsers.forEach(function(u) {
if (u.id == userId) user = u;
});
if (!user) return;
// 先获取该用户角色的默认权限
fetch('/api/permissions/list')
.then(function(r) { return r.json(); })
.then(function(res) {
_rolePagePerms = {};
if (res.code === 0 && Array.isArray(res.data)) {
res.data.forEach(function(p) {
if (p.role === user.role) {
_rolePagePerms[p.page] = p.permission;
}
});
}
// super/admin: 后端直接放行,表里可能没条目,但前端确保显示为有权限
if (user.role === 'super_admin' || user.role === 'admin') {
var hasAny = false;
for (var k in _rolePagePerms) { hasAny = true; break; }
if (!hasAny) {
_permMenuTree.forEach(function(g) {
g.items.forEach(function(item) {
_rolePagePerms[item.page] = 1;
});
});
}
}
_renderPermUserModal(user);
})
.catch(function() {
_rolePagePerms = {};
_renderPermUserModal(user);
});
};
// 菜单树结构(与侧边栏菜单一致)
// 菜单树结构:每个菜单项列出实际的功能按钮(不是通用的增删改查)
var _permMenuTree = [
{
name: '数据概览', icon: '📊', items: [
{page: 'dashboard', name: '数据概览', features: [{id:'view',name:'查看'}]}
]
},
{
name: '出库数据', icon: '📦', items: [
{page: 'data_summary', name: '出库汇总', features: [{id:'view',name:'查看'},{id:'query',name:'查询'},{id:'export',name:'导出'}]},
{page: 'data_detail', name: '出库明细', features: [{id:'view',name:'查看'},{id:'query',name:'查询'},{id:'export',name:'导出'}]}
]
},
{
name: '库存信息', icon: '📋', items: [
{page: 'inventory_finished', name: '成品库存', features: [{id:'view',name:'查看'},{id:'edit_product',name:'编辑产品'},{id:'export',name:'导出'}]},
{page: 'inventory_packaging', name: '包材库存', features: [{id:'view',name:'查看'},{id:'edit_product',name:'编辑产品'},{id:'export',name:'导出'}]}
]
},
{
name: '计划预测', icon: '📈', items: [
{page: 'planning', name: '计划预测', features: [{id:'view',name:'查看'},{id:'batch_order',name:'批量下单'},{id:'field_setting',name:'字段设置'},{id:'frozen',name:'冻结列'}]}
]
},
{
name: '采购订单', icon: '📄', items: [
{page: 'purchase', name: '待下单管理', features: [{id:'view',name:'查看'},{id:'batch_confirm',name:'批量确认下单'},{id:'rollback',name:'回退'},{id:'field_setting',name:'字段设置'},{id:'frozen',name:'冻结列'}]},
{page: 'purchase_ready', name: '下单备份管理', features: [{id:'view',name:'查看'},{id:'rollback',name:'回退'},{id:'field_setting',name:'字段设置'}]},
{page: 'orders_domestic', name: '国内订单管理', features: [{id:'view',name:'查看'}]},
{page: 'orders_overseas', name: '海外订单管理', features: [{id:'view',name:'查看'}]},
{page: 'orders_packaging', name: '包材订单管理', features: [{id:'view',name:'查看'}]},
{page: 'orders_finished', name: '成品提货表', features: [{id:'view',name:'查看'}]}
]
},
{
name: '测试问题', icon: '🐛', items: [
{page: 'feedback', name: '测试问题', features: [{id:'view',name:'查看'},{id:'submit',name:'提交反馈'}]}
]
},
{
name: '基础信息', icon: '📚', items: [
{page: 'products', name: '产品信息管理', features: [{id:'view',name:'查看'},{id:'edit',name:'编辑产品'},{id:'field_setting',name:'字段设置'}]},
{page: 'bom', name: '物料清单管理', features: [{id:'view',name:'查看'},{id:'edit',name:'编辑'}]},
{page: 'warehouse', name: '仓库信息管理', features: [{id:'view',name:'查看'},{id:'create',name:'新增仓库'},{id:'edit',name:'编辑仓库'},{id:'delete',name:'删除仓库'}]},
{page: 'suppliers', name: '供应商管理', features: [{id:'view',name:'查看'},{id:'edit',name:'编辑'}]}
]
},
{
name: '知识库', icon: '📖', items: [
{page: 'knowledge', name: '知识库', features: [{id:'view',name:'查看'},{id:'create',name:'新建文章'},{id:'edit',name:'编辑文章'},{id:'delete',name:'删除文章'},{id:'import',name:'导入'},{id:'export',name:'导出'},{id:'lint',name:'运行检查'},{id:'graph',name:'关系图谱'}]}
]
},
{
name: '系统设置', icon: '⚙️', items: [
{page: 'users', name: '用户管理', features: [{id:'view',name:'查看'},{id:'create',name:'新增'},{id:'edit',name:'编辑'},{id:'delete',name:'删除'},{id:'import',name:'导入'}]},
{page: 'permissions', name: '权限配置', features: [{id:'view',name:'查看'},{id:'edit',name:'编辑'}]},
{page: 'login_records', name: '访问记录', features: [{id:'view',name:'查看'}]},
{page: 'logs', name: '操作日志', features: [{id:'view',name:'查看'}]},
{page: 'config', name: '参数配置', features: [{id:'view',name:'查看'},{id:'edit',name:'编辑'},{id:'reset',name:'恢复默认'}]},
{page: 'config_calibrate', name: '参数校准', features: [{id:'view',name:'查看'},{id:'edit',name:'编辑'}]}
]
}
];
window._renderPermUserModal = function(user) {
var userPerms = user.permissions || {};
var isFullAccess = (user.role === 'super_admin' || user.role === 'admin');
if (isFullAccess) {
_permMenuTree.forEach(function(group) {
group.items.forEach(function(item) {
_rolePagePerms[item.page] = 1;
});
});
}
var html = '<div class="modal-overlay" id="userPermModal" style="display:flex">';
html += '<div class="modal" style="width:900px;max-width:94vw">';
html += '<div class="modal-header">';
html += '<h3>权限配置 — ' + escHtml(user.real_name || user.username) + '</h3>';
html += '<span class="modal-close" onclick="closeUserPermModal()">&times;</span>';
html += '</div>';
html += '<div class="modal-body" style="max-height:72vh;overflow-y:auto;padding:16px 20px">';
_permMenuTree.forEach(function(group) {
html += '<div style="margin-bottom:16px">';
html += '<div style="font-weight:600;font-size:14px;color:#333;padding:6px 0 8px;display:flex;align-items:center;gap:6px;border-bottom:1px solid #f0f0f0;margin-bottom:8px">' + group.icon + ' ' + escHtml(group.name) + '</div>';
group.items.forEach(function(item) {
var pageId = item.page;
var hasRolePerm = _rolePagePerms[pageId] == 1;
var feats = item.features || [];
html += '<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;' + (hasRolePerm ? '' : 'opacity:0.5') + '">';
// 菜单项名称
html += '<span style="font-size:13px;font-weight:500;min-width:100px;flex-shrink:0;' + (hasRolePerm ? 'color:#333' : 'color:#bbb') + '">' + escHtml(item.name) + '</span>';
// 功能按钮列表
feats.forEach(function(f) {
var pagePerms = userPerms[pageId] || {};
var checked;
if (pagePerms[f.id] !== undefined) {
checked = pagePerms[f.id] == 1;
} else if (isFullAccess) {
checked = true;
} else {
checked = hasRolePerm;
}
html += '<label style="display:inline-flex;align-items:center;gap:3px;padding:3px 8px;border-radius:12px;font-size:12px;cursor:pointer;background:' + (checked ? '#e6f7ff' : '#f5f5f5') + ';border:1px solid ' + (checked ? '#91d5ff' : '#e8e8e8') + ';transition:all .15s;user-select:none;white-space:nowrap">';
html += '<input type="checkbox" data-page="' + pageId + '" data-op="' + f.id + '" ' + (checked ? 'checked' : '') + ' style="margin:0;cursor:pointer">';
html += ' ' + escHtml(f.name) + '</label>';
});
html += '</div>';
});
html += '</div>';
});
html += '</div>';
html += '<div class="modal-footer" style="display:flex;justify-content:space-between">';
html += '<div><button class="btn btn-sm" onclick="selectAllPermCbs(true)" style="margin-right:4px">全选</button><button class="btn btn-sm" onclick="selectAllPermCbs(false)">全不选</button></div>';
html += '<div><button class="btn" onclick="closeUserPermModal()">取消</button><button class="btn btn-primary" onclick="saveUserPerm()">保存</button></div>';
html += '</div></div></div>';
document.body.insertAdjacentHTML('beforeend', html);
};
window.closeUserPermModal = function() {
var m = document.getElementById('userPermModal');
if (m) m.remove();
_editingUserId = null;
};
window.selectAllPermCbs = function(checked) {
var m = document.getElementById('userPermModal');
if (!m) return;
m.querySelectorAll('input[type=checkbox]').forEach(function(cb) {
cb.checked = checked;
});
};
window.saveUserPerm = function() {
if (!_editingUserId) return;
var m = document.getElementById('userPermModal');
if (!m) return;
var items = [];
m.querySelectorAll('input[type=checkbox]').forEach(function(cb) {
var page = cb.getAttribute('data-page');
var op = cb.getAttribute('data-op');
if (page && op) {
items.push({
page: page,
operation: op,
permission: cb.checked ? 1 : 0
});
}
});
fetch('/api/permissions/user/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user_id: _editingUserId, items: items})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
closeUserPermModal();
loadPermissionsUsers();
} else {
alert(res.msg || '保存失败');
}
})
.catch(function() {
alert('保存失败,请检查网络');
});
};
function loadConfigPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>参数配置</h1>
<p class="page-desc">趋势判断与销量预测基础配置参数</p>
</div>
<div id="configContent"><div class="page-loading"><div class="loading-spinner"></div></div></div>
`;
fetch('/api/config/list')
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
const d = res.data;
const el = document.getElementById('configContent');
let trendHtml = renderConfigTable('趋势判断基础配置参数',
['趋势类型', '判断条件', '说明'],
['trend_type', 'condition_expr', 'notes'],
d.trend_config);
let salesHtml = renderConfigTable('日销量预测基础配置参数',
['3天平均销量', '7天平均销量', '14天平均销量', '调节系数', '趋势类型'],
['avg_3d', 'avg_7d', 'avg_14d', 'adjust_factor', 'trend_type'],
d.sales_predict_config);
let globalHtml = '<div class="card" style="margin-top:15px"><div class="card-header"><h3>全局配置参数</h3></div><div class="card-body">';
globalHtml += '<table class="data-table"><thead><tr><th>参数名称</th><th>参数值</th><th>说明</th><th class="col-action" style="width:120px;min-width:120px;position:sticky;right:0;background:var(--bg,#fff);z-index:3;border-left:1px solid var(--border)">操作</th></tr></thead><tbody>';
(d.global_config || []).forEach(function(g) {
globalHtml += '<tr>';
globalHtml += '<td><code>' + escHtml(g.config_key) + '</code></td>';
globalHtml += '<td><input type="text" id="cfg-' + escHtml(g.config_key) + '" class="form-input" style="width:100px" value="' + escHtml(g.config_value) + '"></td>';
globalHtml += '<td>' + escHtml(g.description || '') + '</td>';
globalHtml += '<td><button class="btn btn-sm btn-primary" onclick="saveGlobalConfig()">保存</button></td>';
globalHtml += '</tr>';
});
globalHtml += '</tbody></table></div></div>';
window.saveGlobalConfig = function() {
var items = [];
(d.global_config || []).forEach(function(g) {
var input = document.getElementById("cfg-" + g.config_key);
if (input) {
items.push({config_key: g.config_key, config_value: input.value});
}
});
fetch('/api/config/save_global', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(items)
})
.then(r => r.json())
.then(res => { alert(res.msg || '保存成功'); });
};
el.innerHTML = trendHtml + salesHtml + globalHtml;
});
}
function renderConfigTable(title, labels, fields, data) {
let html = '<div class="card" style="margin-bottom:20px">';
html += '<div class="card-header"><h3>' + title + '</h3></div>';
html += '<div class="card-body" style="padding:0;overflow-x:auto">';
html += '<table class="data-table"><thead><tr>';
labels.forEach(function(l) { html += '<th>' + l + '</th>'; });
html += '</tr></thead><tbody>';
data.forEach(function(r) {
html += '<tr>';
fields.forEach(function(f) {
var val = r[f];
if (val === null || val === undefined) val = '-';
html += '<td>' + escHtml(String(val)) + '</td>';
});
html += '</tr>';
});
html += '</tbody></table>';
html += '<div style="padding:10px 16px;font-size:12px;color:var(--text-light)">共 ' + data.length + ' 条记录</div>';
html += '</div></div>';
return html;
}
// 可排序的数值列(点击表头排序)
var SORTABLE_FIELDS = {
'昨日销量':1, '库存':1, '加工厂库存':1, '生产在途':1, '采购在途':1,
'日销量预测':1, '库存健康指数':1, '成本价':1, 'cost_price':1,
'3天平均销量':1, '7天平均销量':1, '14天平均销量':1,
'30天平均销量':1, '60天平均销量':1,
'3天最高销量':1, '7天最高销量':1, '14天最高销量':1, '30天最高销量':1,
'14天最低销量':1, '14天标准差':1,
'库存可支撑天数':1, '库存工厂可支撑天数':1, '加工厂库存支撑天数':1,
'库存工厂待生产可支撑天数':1, '总支撑天数':1,
'最低销量偏差率':1, '最高销量偏差率':1, '14天波动率':1,
'建议下单数量':1, '实际下单数量':1, '建议回货数量':1,
'生产货期':1, '物流时间':1, '最低起订量':1
};
// ─── 计划预测页面 ───
// 所有计划列定义(按照指定顺序和显隐)
const PLAN_COLUMNS = [
{field: 'product_type', label: '产品类型', defaultShow: true},
{field: '产品种类', label: '产品种类', defaultShow: false},
{field: 'product_code', label: '货品编号', defaultShow: true},
{field: 'product_name', label: '货品名称', defaultShow: true},
{field: 'bom', label: '物料清单', defaultShow: true},
{field: '产品分级', label: '产品分级', defaultShow: true},
{field: '昨日销量', label: '昨日销量', defaultShow: true},
{field: '销售趋势', label: '销售趋势', defaultShow: true},
{field: '库存', label: '库存', defaultShow: true},
{field: '加工厂库存', label: '加工厂库存', defaultShow: true},
{field: '生产在途', label: '生产在途', defaultShow: true},
{field: '采购在途', label: '采购在途', defaultShow: true},
{field: '日销量预测', label: '日销量预测', defaultShow: true},
{field: '建议下单数量', label: '建议下单数量', defaultShow: true},
{field: '实际下单数量', label: '实际下单数量', defaultShow: true},
{field: '正式下单数量', label: '正式下单数量', defaultShow: true},
{field: '库存健康指数', label: '库存健康指数', defaultShow: true},
{field: '风险把控', label: '风险把控', defaultShow: true},
{field: '供应商名称', label: '供应商名称', defaultShow: true},
{field: '采购跟单', label: '采购跟单', defaultShow: true},
{field: '采购计划', label: '采购计划', defaultShow: true},
{field: '开发专员', label: '开发专员', defaultShow: true},
{field: '归属平台', label: '归属平台', defaultShow: true},
{field: '运营专员', label: '运营专员', defaultShow: true},
{field: 'cost_price', label: '成本价', defaultShow: false},
{field: '3天平均销量', label: '3天平均销量', defaultShow: false},
{field: '7天平均销量', label: '7天平均销量', defaultShow: false},
{field: '14天平均销量', label: '14天平均销量', defaultShow: false},
{field: '库存可支撑天数', label: '库存可支撑天数', defaultShow: false},
{field: '库存工厂可支撑天数', label: '现货采购在途', defaultShow: false},
{field: '加工厂库存支撑天数', label: '加工厂库存支撑', defaultShow: false},
{field: '库存工厂待生产可支撑天数', label: '定制品在途', defaultShow: false},
{field: '总支撑天数', label: '总支撑天数', defaultShow: false},
{field: '建议回货数量', label: '建议回货数量', defaultShow: false},
{field: 'specification', label: '产品规格', defaultShow: false},
{field: '30天平均销量', label: '30天平均销量', defaultShow: false},
{field: '60天平均销量', label: '60天平均销量', defaultShow: false},
{field: '3天最高销量', label: '3天最高销量', defaultShow: false},
{field: '7天最高销量', label: '7天最高销量', defaultShow: false},
{field: '14天最高销量', label: '14天最高销量', defaultShow: false},
{field: '30天最高销量', label: '30天最高销量', defaultShow: false},
{field: '14天最低销量', label: '14天最低销量', defaultShow: false},
{field: '14天标准差', label: '14天标准差', defaultShow: false},
{field: '最低销量偏差率', label: '最低销量偏差率', defaultShow: false},
{field: '最高销量偏差率', label: '最高销量偏差率', defaultShow: false},
{field: '极值判断', label: '极值判断', defaultShow: false},
{field: '14天波动率', label: '14天波动率', defaultShow: false},
{field: '波动风险', label: '波动风险', defaultShow: false},
{field: '生产货期', label: '生产货期', defaultShow: false},
{field: '物流时间', label: '物流时间', defaultShow: false},
{field: '最低起订量', label: '最低起订量', defaultShow: false},
{field: '创建时间', label: '创建时间', defaultShow: false},
{field: '是否新品', label: '是否新品', defaultShow: false},
{field: '一级类目', label: '一级类目', defaultShow: false},
{field: '二级类目', label: '二级类目', defaultShow: false},
{field: '三级类目', label: '三级类目', defaultShow: false},
];
// 筛选字段预定义选项(下拉选择类型)
const PLAN_FILTER_OPTIONS = {
'product_type': ['成品', '包材', '现货', '赠品', '清库存', '其他'],
'产品分级': ['S级', 'A级', 'B级', 'C级', '不参与评级'],
'销售趋势': ['开始上升', '开始下跌', '平稳趋势', '上升趋稳', '下跌趋稳', '持续上升', '持续下跌', '急增', '急跌', '近期无销售'],
'是否新品': ['新品', '返单品'],
'风险把控': ['正常', '新品', '呆滞品', '需启动清库存'],
'波动风险': ['X', 'Y', 'Z'],
'极值判断': ['正常', '极大', '极小', '极大/极小'],
'产品种类': ['成品', '包材', '现货', '赠品', '清库存', '其他'],
'库存健康指数': ['999']
};
// 动态筛选字段中排除的计算字段(这些值来自后台计算,不可直接查询)
const PLAN_FILTER_EXCLUDE_FIELDS = {
'产品分级':1, '昨日销量':1, '销售趋势':1, '库存':1, '加工厂库存':1,
'生产在途':1, '采购在途':1, '日销量预测':1, '库存健康指数':1, '风险把控':1,
'库存可支撑天数':1, '库存工厂可支撑天数':1, '加工厂库存支撑天数':1,
'库存工厂待生产可支撑天数':1, '总支撑天数':1,
'建议下单数量':1, '实际下单数量':1, '建议回货数量':1,
'3天平均销量':1, '7天平均销量':1, '14天平均销量':1,
'30天平均销量':1, '60天平均销量':1, '3天最高销量':1, '7天最高销量':1,
'14天最高销量':1, '30天最高销量':1, '14天最低销量':1, '14天标准差':1,
'最低销量偏差率':1, '最高销量偏差率':1, '极值判断':1, '14天波动率':1, '波动风险':1,
'是否新品':1
};
// 字段显隐状态(实际生效的)
if (!window.planColVisible) {
window.planColVisible = {};
PLAN_COLUMNS.forEach((c, i) => { window.planColVisible[c.field] = c.defaultShow; window['_planColIdx_' + c.field] = i; });
}
// 弹窗中的临时状态(确定后才生效)
if (!window._planPendingVisible) {
window._planPendingVisible = {};
PLAN_COLUMNS.forEach((c, i) => { window._planPendingVisible[c.field] = c.defaultShow; });
}
// 弹窗中拖拽后的列顺序(存储字段名数组)
if (!window._planColDragOrder) {
window._planColDragOrder = PLAN_COLUMNS.map(c => c.field);
}
// 行内编辑功能
window.prodInlineEditClick = function(el) {
inlineEditClick(el);
};
window.inlineEditClick = function(el) {
if (window._currentRole !== 'admin') return;
var code = el.dataset.code;
var field = el.dataset.field;
var val = el.dataset.val;
var input = document.createElement('input');
input.type = 'text';
input.className = 'form-input';
input.style.width = '80px';
input.style.height = '28px';
input.style.padding = '2px 6px';
input.value = val === '点击编辑' ? '' : val;
input.dataset.code = code;
input.dataset.field = field;
el.parentNode.replaceChild(input, el);
input.focus();
input.select();
function saveEdit() {
var newVal = input.value.trim();
var fieldMap = {'生产货期':'lead_time','物流时间':'logistics_time','最低起订量':'min_order_qty'};
var apiField = fieldMap[field] || field;
fetch('/api/product/update_fields', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: code, field: apiField, value: newVal || '0'})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) { alert(res.msg); }
loadPlanPage(planCurrentPage);
})
.catch(function(e) { alert('保存失败'); });
}
input.addEventListener('blur', saveEdit);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { input.blur(); }
if (e.key === 'Escape') {
var span = document.createElement('span');
span.className = 'inline-edit';
span.dataset.code = code;
span.dataset.field = field;
span.dataset.val = val;
span.onclick = function() { inlineEditClick(this); };
span.textContent = val || (window._currentRole === 'admin' ? '点击编辑' : '-');
input.parentNode.replaceChild(span, input);
}
});
}
function applyPlanColVisible() {
PLAN_COLUMNS.forEach((c, i) => {
window['_planColIdx_' + c.field] = i;
const show = window.planColVisible[c.field];
document.querySelectorAll('.plan-col-' + i).forEach(el => {
el.style.display = show ? '' : 'none';
});
document.querySelectorAll('.plan-th-' + i).forEach(el => {
el.style.display = show ? '' : 'none';
});
});
}
function renderPlanColCheckboxes(grid) {
if (!grid) grid = document.getElementById('planColGrid');
if (!grid) return;
grid.innerHTML = '';
const order = window._planColDragOrder || PLAN_COLUMNS.map(c => c.field);
order.forEach((field, i) => {
const col = PLAN_COLUMNS.find(c => c.field === field);
if (!col) return;
const checked = window._planPendingVisible[field] ? 'checked' : '';
const label = document.createElement('label');
label.draggable = true;
label.dataset.field = field;
label.style.cssText = 'padding:4px 6px;border-radius:4px;cursor:grab;user-select:none;display:flex;align-items:center;gap:4px';
label.innerHTML = `<span style="color:#bbb;font-size:12px;cursor:grab">⠿</span><input type="checkbox" id="plan-cb-${field}" ${checked} onchange="togglePlanCol('${field}')"> ${col.label}`;
label.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('text/plain', field);
this.style.opacity = '0.4';
});
label.addEventListener('dragend', function(e) {
this.style.opacity = '1';
document.querySelectorAll('#planColGrid label').forEach(el => el.style.border = '');
});
label.addEventListener('dragover', function(e) {
e.preventDefault();
document.querySelectorAll('#planColGrid label').forEach(el => el.style.border = '');
this.style.border = '2px dashed var(--primary)';
});
label.addEventListener('dragleave', function(e) {
this.style.border = '';
});
label.addEventListener('drop', function(e) {
e.preventDefault();
document.querySelectorAll('#planColGrid label').forEach(el => el.style.border = '');
const fromField = e.dataTransfer.getData('text/plain');
const toField = this.dataset.field;
if (fromField === toField) return;
const order = window._planColDragOrder;
const fromIdx = order.indexOf(fromField);
const toIdx = order.indexOf(toField);
if (fromIdx === -1 || toIdx === -1) return;
order.splice(fromIdx, 1);
order.splice(toIdx, 0, fromField);
renderPlanColCheckboxes();
});
grid.appendChild(label);
});
}
function openPlanColModal() {
// 初始化拖拽顺序
window._planColDragOrder = PLAN_COLUMNS.map(c => c.field);
const grid = document.getElementById('planColGrid');
if (grid) renderPlanColCheckboxes(grid);
// 把当前生效状态复制到临时状态
PLAN_COLUMNS.forEach((c, i) => {
window._planPendingVisible[c.field] = window.planColVisible[c.field];
const cb = document.getElementById('plan-cb-' + c.field);
if (cb) cb.checked = window.planColVisible[c.field];
});
// 同步全选checkbox状态
const allCb = document.getElementById('plan-cb-all');
if (allCb) {
allCb.checked = PLAN_COLUMNS.every(c => window.planColVisible[c.field]);
}
// 重置搜索框
const searchInput = document.getElementById('planColSearchInput');
if (searchInput) {
searchInput.value = '';
}
filterPlanColCheckboxes();
document.getElementById('planColModal').style.display = 'flex';
// 刷新方案列表
refreshPlanSchemeList();
}
function confirmPlanCol() {
// 应用拖拽后的新顺序
var newOrder = window._planColDragOrder;
if (newOrder) {
var reordered = [];
newOrder.forEach(function(field) {
var col = PLAN_COLUMNS.find(function(c) { return c.field === field; });
if (col) reordered.push(col);
});
PLAN_COLUMNS.forEach(function(c) {
if (newOrder.indexOf(c.field) === -1) reordered.push(c);
});
PLAN_COLUMNS.length = 0;
reordered.forEach(function(c) { PLAN_COLUMNS.push(c); });
PLAN_COLUMNS.forEach(function(c, i) { window['_planColIdx_' + c.field] = i; });
}
PLAN_COLUMNS.forEach(function(c, i) {
window.planColVisible[c.field] = window._planPendingVisible[c.field];
});
planCurrentPage = Math.max(1, planCurrentPage || 1);
// 全量刷新页面(确保冻结列的 sticky 位置正确)
if (window._pageCache) delete window._pageCache['planning'];
loadPage('planning');
document.getElementById('planColModal').style.display = 'none';
}
function closePlanColModal() {
document.getElementById('planColModal').style.display = 'none';
}
function togglePlanCol(field) {
window._planPendingVisible[field] = !window._planPendingVisible[field];
const show = window._planPendingVisible[field];
const cb = document.getElementById('plan-cb-' + field);
if (cb) cb.checked = show;
}
function filterPlanColCheckboxes() {
const keyword = (document.getElementById('planColSearchInput')?.value || '').trim();
const labels = document.querySelectorAll('#planColCheckboxes label');
labels.forEach(label => {
if (!keyword) {
label.style.display = '';
} else {
const text = label.textContent || '';
label.style.display = text.includes(keyword) ? '' : 'none';
}
});
}
function togglePlanColPendingAll(checked) {
PLAN_COLUMNS.forEach((c, i) => {
window._planPendingVisible[c.field] = checked;
const cb = document.getElementById('plan-cb-' + c.field);
if (cb) cb.checked = checked;
});
}
function loadPlanningPage(container) {
// 冻结列:计算所有可见的冻结列及其 left 偏移
var frozenLefts = {};
if (planFrozen) {
var leftAccum = 86; // 勾选列36px + 序号列50px
frozenLefts['_cb'] = 0;
frozenLefts['_index'] = 36;
planFrozenFields.forEach(function(f) {
if (window.planColVisible[f]) {
frozenLefts[f] = leftAccum;
leftAccum += (FROZEN_WIDTHS[f] || 100);
}
});
}
var frozenStyle = 'position:sticky;top:0;z-index:2';
if (planFrozen) frozenStyle = 'position:sticky;left:0;top:0;z-index:999;background:var(--bg-card);transform:translateZ(0)';
let theadHtml = '<tr><th class="plan-col-cb" style="width:36px;min-width:36px;' + frozenStyle + '"><input type="checkbox" id="planSelectAllCb" onchange="planToggleCurrentPage(this.checked)" title="全选当前页"></th><th class="plan-col-index" style="width:50px;' + frozenStyle + '"><div style="text-align:center;width:100%">序号</div></th>';
PLAN_COLUMNS.forEach((c, i) => {
const visibleClass = window.planColVisible[c.field] ? '' : ' style="display:none"';
var sortArrow = (planSortField === c.field) ? (planSortAsc ? ' ▲' : ' ▼') : '';
var isSortable = (c.field === '产品分级') || SORTABLE_FIELDS[c.field];
var planTips = {
'日销量预测': '日销量预测 = (3天平均×w3 + 7天平均×w7 + 14天平均×w14) × 调整系数',
'库存可支撑天数': '库存可支撑天数 = 库存 / 日销量预测',
'加工厂库存支撑天数': '加工厂库存支撑 = 加工厂库存 / 日销量预测',
'库存工厂可支撑天数': '现货采购在途 = 采购在途 / 日销量预测',
'库存工厂待生产可支撑天数': '定制品在途 = 生产在途 / 日销量预测',
'总支撑天数': '总支撑天数 = (库存+加工厂库存+生产在途+采购在途) / 日销量预测',
'库存健康指数': '日销量预测>0:库存可支撑天数÷(货期+物流);=0:库存可支撑天数÷(货期+物流)',
'建议下单数量': '日销量预测=0且库存>0则0,否则(安全库存天数+货期+物流)×日销量预测',
'实际下单数量': '建议下单数量向上取整到1000',
'正式下单数量': '正式下单数量初始等于实际下单数量,可人工修改',
'建议回货数量': 'ROUNDUP((日销量预测×回货天数-库存)×10)/10,上限在途总数',
'是否新品': '创建时间5个月以内为新品,否则返单品',
'成本价': '从产品信息匹配的成本价格',
'销售趋势': '基于3/7/14天日均销量判断的9种趋势类型'
};
var tip = planTips[c.label] || '';
var thPos = 'position:sticky;top:0;z-index:10';
if (planFrozen && frozenLefts[c.field] !== undefined) {
thPos = 'position:sticky;top:0;left:' + frozenLefts[c.field] + 'px;z-index:999;background:var(--bg-card);transform:translateZ(0);min-width:' + (FROZEN_WIDTHS[c.field] || 100) + 'px';
}
var filterIcon = '<span style="margin-left:4px;font-size:11px;color:var(--text-light);cursor:pointer" onclick="event.stopPropagation();openPlanFilterMenu(\'' + escJs(c.field) + '\', event)" title="筛选">▾</span>';
theadHtml += `<th class="plan-col-${i} plan-th-${i}"${visibleClass} onclick="openPlanFilterMenu('${escJs(c.field)}', event)" style="cursor:pointer;${thPos}"${tip ? ' title="' + escHtml(tip) + '"' : ''}>${c.label}${sortArrow}${filterIcon}</th>`;
});
theadHtml += '<th class="plan-col-action" style="width:200px;min-width:200px;position:sticky;top:0;right:0;background:var(--bg,#fff);z-index:11;border-left:1px solid var(--border)">操作</th>';
theadHtml += '</tr>';
let colToggleHtml = '<button class="btn btn-sm" id="planFrozenBtn" style="margin-right:6px">🔒 冻结列</button>';
colToggleHtml += '<button class="btn btn-sm" onclick="openPlanColModal()">⚙ 字段设置</button>';
container.innerHTML = `
<style>
.filter-select-wrap { position:relative; display:inline-block; }
.filter-select-wrap select { padding-right:20px !important; }
.filter-clear-btn:hover { color:#ff4d4f !important; background:#fff1f0 !important; }
.filter-clear-btn { font-family:sans-serif; }
</style>
<div class="page-header" style="padding:8px 0;margin-bottom:4px">
<h1 style="font-size:18px;margin:0">计划预测</h1>
<p class="page-desc" style="font-size:12px;margin:2px 0 0">产品计划与预测数据</p>
</div>
<style>
.plan-tabs { display:flex;gap:4px;margin-bottom:14px;background:var(--bg-card,#f7f8fa);padding:4px;border-radius:10px; }
.plan-tab { padding:8px 22px;font-size:13px;font-weight:600;cursor:pointer;color:var(--text-light,#888);border-radius:8px;transition:all .25s;user-select:none; }
.plan-tab:hover { color:var(--text,#333);background:rgba(0,0,0,0.04); }
.plan-tab:nth-child(1).active { color:#1890ff;background:#e6f7ff;box-shadow:0 1px 4px rgba(24,144,255,0.12); }
.plan-tab:nth-child(2).active { color:#13c2c2;background:#e6fffb;box-shadow:0 1px 4px rgba(19,194,194,0.12); }
.plan-tab:nth-child(3).active { color:#f5a623;background:#fff7e6;box-shadow:0 1px 4px rgba(245,166,35,0.12); }
</style>
<div class="plan-tabs" id="planTabs">
<div class="plan-tab active" data-view="default" onclick="switchPlanView('default')">计划预测</div>
<div class="plan-tab" data-view="cloud" onclick="switchPlanView('cloud')">计划预测-云仓</div>
<div class="plan-tab" data-view="external" onclick="switchPlanView('external')">计划预测-外仓</div>
</div>
<div class="card" style="margin-bottom:8px">
<div class="card-body" style="padding:8px 12px">
<div class="filter-bar" style="flex-wrap:wrap;gap:6px">
<input type="text" id="planSearch" class="form-input" style="width:200px" placeholder="🔍 货品编号/货品名称" autocomplete="off" onkeydown="if(event.key==='Enter')loadPlanPage(1)">
<span style="font-size:13px;color:var(--text-secondary);margin-left:4px">创建时间</span>
<select id="planDateCond" class="form-input" style="width:80px">
<option value="">全部</option>
<option value="lt">早于</option>
<option value="eq">等于</option>
<option value="gt">晚于</option>
</select>
<input type="date" id="planDateVal" class="form-input" style="width:200px">
<input type="text" id="planSupplierFilter" class="form-input" style="width:240px" placeholder="供应商名称" autocomplete="off" onkeydown="if(event.key==='Enter')planFilterApply()">
<span class="filter-select-wrap"><select id="planFollowerFilter" class="form-input" style="width:auto;min-width:80px">
<option value="">采购跟单</option>
</select></span>
<span class="filter-select-wrap"><select id="planPlannerFilter" class="form-input" style="width:auto;min-width:80px">
<option value="">采购计划</option>
</select></span>
<button class="btn btn-primary" onclick="searchPlanning()">查询</button>
<button class="btn" onclick="resetPlanningFilters()">重置</button>
<span class="data-count" id="planCount"></span>
</div>
</div>
</div>
<div class="card" style="margin-top:15px">
<div class="card-header" style="display:flex;align-items:center">
<h3 style="flex:0 0 auto">产品计划列表</h3>
<div style="display:flex;align-items:center;gap:8px;margin-left:16px">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px;user-select:none">
<input type="checkbox" id="planSelectAllCb2" onchange="planToggleAllRows(this.checked)"> <strong>全选(所有数据)</strong>
</label>
<button class="btn btn-sm" style="background:#e6fffb;color:#13c2c2;border:1px solid #87e8de" onclick="planBatchOrder()">📦 批量下单</button>
<span id="planSelectedCount" style="font-size:13px;color:var(--primary);font-weight:600"></span>
</div>
<div style="margin-left:auto">
${colToggleHtml}
</div>
</div>
<div class="card-body" style="padding:0;overflow-x:auto">
<table class="data-table" id="planTable">
<thead>${theadHtml}</thead>
<tbody id="planTbody"></tbody>
</table>
</div>
</div>
<div class="pagination" id="planPagination"></div>
<!-- 字段设置弹窗 -->
<div class="modal-overlay" id="planColModal" style="display:none">
<div class="modal" style="width:720px">
<div class="modal-header">
<h3>字段设置</h3>
<span class="modal-close" onclick="closePlanColModal()">&times;</span>
</div>
<div class="modal-body" style="max-height:65vh;overflow-y:auto;padding:16px 20px">
<!-- 方案管理 -->
<div style="margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid var(--border)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-weight:600;font-size:14px;white-space:nowrap">📁 方案管理</span>
<input type="text" id="planSchemeNameInput" class="form-input" placeholder="输入方案名称..." style="flex:1;min-width:0">
<button class="btn btn-primary btn-sm" onclick="savePlanScheme()">💾 保存方案</button>
</div>
<div id="planSchemeList" style="display:flex;flex-wrap:wrap;gap:6px;min-height:28px">
<span style="color:var(--text-light);font-size:13px">暂无保存方案</span>
</div>
</div>
<div class="col-toggle-all" style="padding-bottom:8px;border-bottom:1px solid var(--border);margin-bottom:8px">
<label class="cb-all"><input type="checkbox" id="plan-cb-all" checked onchange="togglePlanColPendingAll(this.checked)"> <strong>全选</strong></label>
</div>
<input type="text" id="planColSearchInput" class="form-input" placeholder="搜索字段名称..." oninput="filterPlanColCheckboxes()" style="margin-bottom:8px;width:100%;box-sizing:border-box">
<div id="planColCheckboxes"></div>
</div>
<div class="modal-footer" style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:12px;color:var(--text-light)">拖拽字段名可调整列顺序</span>
<div>
<button class="btn" onclick="closePlanColModal()">取消</button>
<button class="btn btn-primary" onclick="confirmPlanCol()">确定</button>
</div>
</div>
</div>
</div>
<!-- 冻结列选择弹窗 -->
<div class="modal-overlay" id="planFrozenModal" style="display:none">
<div class="modal" style="width:400px">
<div class="modal-header">
<h3>🔒 冻结列设置</h3>
<span class="modal-close" onclick="closePlanFrozenModal()">&times;</span>
</div>
<div class="modal-body" style="max-height:60vh;overflow-y:auto;padding:12px 20px" id="planFrozenModalBody">
</div>
<div class="modal-footer">
<button class="btn" onclick="closePlanFrozenModal()">取消</button>
<button class="btn btn-primary" onclick="confirmPlanFrozen()">确定</button>
</div>
</div>
</div>
`;
// 冻结列按钮 - 事件绑定
var frozenBtn = document.getElementById('planFrozenBtn');
if (frozenBtn) frozenBtn.onclick = openPlanFrozenModal;
// 填充字段设置弹窗(3列网格,可拖拽排序)
const cbContainer = document.getElementById('planColCheckboxes');
if (cbContainer) {
cbContainer.innerHTML = '<div id="planColGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px 12px;padding:4px 0"></div>';
const grid = document.getElementById('planColGrid');
window._planColDragOrder = PLAN_COLUMNS.map(c => c.field);
renderPlanColCheckboxes(grid);
}
// 禁用浏览器表单自动填充
var planSearchEl = document.getElementById('planSearch');
if (planSearchEl) planSearchEl.setAttribute('autocomplete', 'off');
// 初始化下拉清除按钮
setupFilterClearButtons();
loadPlanPage(1);
// 加载默认方案
loadDefaultPlanScheme();
}
// 采购跟单/采购计划下拉框:选中值时悬浮显示×清除按钮
function setupFilterClearButtons() {
var items = [
{ el: document.getElementById('planFollowerFilter'), name: '采购跟单' },
{ el: document.getElementById('planPlannerFilter'), name: '采购计划' }
];
items.forEach(function(item) {
var sel = item.el;
if (!sel) return;
var wrap = sel.parentNode;
if (!wrap.classList.contains('filter-select-wrap')) return;
// 防重复
if (wrap.querySelector('.filter-clear-btn')) return;
var clearBtn = document.createElement('span');
clearBtn.className = 'filter-clear-btn';
clearBtn.innerHTML = '✕';
clearBtn.style.cssText = 'display:none;position:absolute;right:4px;top:50%;transform:translateY(-50%);width:16px;height:16px;line-height:16px;text-align:center;font-size:12px;cursor:pointer;color:#999;background:#fff;border-radius:50%;z-index:5;user-select:none';
clearBtn.title = '清除筛选';
wrap.style.position = 'relative';
wrap.appendChild(clearBtn);
// 更新清除按钮显隐
function updateClearBtn() {
clearBtn.style.display = sel.value ? 'block' : 'none';
}
updateClearBtn();
sel.addEventListener('change', function() {
updateClearBtn();
// 选中值变化时自动查询
searchPlanning();
});
sel.addEventListener('mouseenter', function() { if (sel.value) clearBtn.style.display = 'block'; });
sel.addEventListener('mouseleave', function() { clearBtn.style.display = 'none'; });
clearBtn.addEventListener('mouseenter', function() { clearBtn.style.display = 'block'; });
clearBtn.addEventListener('mouseleave', function() { clearBtn.style.display = 'none'; });
clearBtn.addEventListener('click', function(e) {
e.stopPropagation();
sel.value = '';
clearBtn.style.display = 'none';
searchPlanning();
});
});
}
let planCurrentPage = 1;
let planTotal = 0;
let PLAN_PAGE_SIZE = 20;
let planSortField = '';
let planSortAsc = false;
const GRADE_ORDER = {'S级':0, 'A级':1, 'B级':2, 'C级':3, '不参与评级':4};
function planSortRows(rows) {
if (!planSortField) return rows;
return rows.sort(function(a, b) {
var va = a[planSortField], vb = b[planSortField];
if (planSortField === '产品分级') {
va = GRADE_ORDER[va] !== undefined ? GRADE_ORDER[va] : 999;
vb = GRADE_ORDER[vb] !== undefined ? GRADE_ORDER[vb] : 999;
} else {
if (va === null || va === undefined || va === '') va = '';
if (vb === null || vb === undefined || vb === '') vb = '';
va = isNaN(Number(va)) ? String(va).toLowerCase() : Number(va);
vb = isNaN(Number(vb)) ? String(vb).toLowerCase() : Number(vb);
}
if (va < vb) return planSortAsc ? -1 : 1;
if (va > vb) return planSortAsc ? 1 : -1;
return 0;
});
}
function loadPlanPage(page) {
planCurrentPage = page;
const search = document.getElementById('planSearch')?.value || '';
const dateCond = document.getElementById('planDateCond')?.value || '';
const dateVal = document.getElementById('planDateVal')?.value || '';
const supplierFilter = document.getElementById('planSupplierFilter')?.value || '';
const followerFilter = document.getElementById('planFollowerFilter')?.value || '';
const plannerFilter = document.getElementById('planPlannerFilter')?.value || '';
// 从全局筛选状态读取
var filterValue = '';
if (window._planFilters) {
var activeCount = 0;
var filters = [];
Object.keys(window._planFilters).forEach(function(f) {
var fp = window._planFilters[f];
if (fp) { fp.field = f; filters.push(fp); activeCount++; }
});
if (activeCount > 0) {
filterValue = '__multi__:' + JSON.stringify(filters);
}
}
let url = '/api/planning/list?page=' + page + '&size=' + PLAN_PAGE_SIZE;
if (search) url += '&search=' + encodeURIComponent(search);
if (filterValue) url += '&custom_field=__multi__&custom_value=' + encodeURIComponent(filterValue);
if (dateCond && dateVal) url += '&date_cond=' + encodeURIComponent(dateCond) + '&date_val=' + encodeURIComponent(dateVal);
if (supplierFilter) url += '&supplier=' + encodeURIComponent(supplierFilter);
if (followerFilter) url += '&procurement_follower=' + encodeURIComponent(followerFilter);
if (plannerFilter) url += '&procurement_planner=' + encodeURIComponent(plannerFilter);
if (planSortField) url += '&sort_by=' + encodeURIComponent(planSortField) + '&sort_order=' + (planSortAsc ? 'asc' : 'desc');
if (planActiveView !== 'default') url += '&view=' + encodeURIComponent(planActiveView);
fetch(url)
.then(r => r.json())
.then(res => {
if (res.code !== 0) return;
const data = res.data;
planTotal = data.total;
window._planCurrentAllCodes = data.all_codes || [];
renderPlanTable(planSortRows(data.rows));
document.getElementById('planCount').textContent = '共 ' + data.total + ' 条';
// 填充采购跟单/采购计划下拉选项(仅首次加载或当页面为第1页时更新)
if (page === 1) { populatePlanPersonFilter(data.rows); setupFilterClearButtons(); }
const pel = document.getElementById('planPagination');
if (pel) renderPaginationWithSize(pel, planCurrentPage, planTotal, PLAN_PAGE_SIZE, 'loadPlanPage', 'PLAN_PAGE_SIZE');
});
}
function renderPlanTable(rows) {
const tbody = document.getElementById('planTbody');
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="' + (PLAN_COLUMNS.length + 1) + '" style="text-align:center;color:var(--text-light);padding:40px">暂无数据</td></tr>';
return;
}
const startIdx = (planCurrentPage - 1) * PLAN_PAGE_SIZE;
const typeMap = {'成品':'blue','包材':'orange','赠品':'green','小样':'purple','物料':'orange','其他':'grey'};
tbody.innerHTML = rows.map((r, rowIdx) => {
const vals = PLAN_COLUMNS.map(c => {
const raw = r[c.field];
if (c.field === 'product_type') {
const color = typeMap[raw] || 'grey';
return `<span class="tag tag-${color}">${escHtml(raw || '-')}</span>`;
}
if (c.field === '产品种类') {
const color = typeMap[raw] || 'grey';
return `<span class="tag tag-${color}">${escHtml(raw || '-')}</span>`;
}
if (c.field === '产品种类') {
const color = typeMap[raw] || 'grey';
return `<span class="tag tag-${color}">${escHtml(raw || '-')}</span>`;
}
if (c.field === 'product_code') {
return `<a href="javascript:;" onclick="showProductDetail('${escHtml(raw)}')" class="sku-link"><code>${escHtml(raw || '-')}</code></a>`;
}
if (c.field === 'product_name') {
return escHtml(truncate(raw || '-', 25));
}
if (c.field === 'bom') {
var pcode = (r.product_code || '');
return '<button class="btn btn-sm btn-outline" onclick="planToggleBom(\'' + escHtml(pcode) + '\')" title="查看物料清单" style="font-size:12px;padding:2px 10px">📋 BOM</button>';
}
if (c.field === '生产货期' || c.field === '物流时间' || c.field === '最低起订量') {
var isAdmin = window._currentRole === 'admin';
var val = raw ?? '';
return '<span class="inline-edit" data-code="' + escHtml(r.product_code || '') + '" data-field="' + c.field + '" data-val="' + val + '" ' + (isAdmin ? 'onclick="inlineEditClick(this)"' : '') + '>' + (val === '' || val === null || val === undefined ? (isAdmin ? '点击编辑' : '-') : val) + '</span>';
}
if (c.field === '创建时间') {
return raw || '-';
}
if (c.field === '昨日销量' || c.field === '3天平均销量' || c.field === '7天平均销量' || c.field === '14天平均销量' || c.field === '30天平均销量' || c.field === '60天平均销量' || c.field === '3天最高销量' || c.field === '7天最高销量' || c.field === '14天最高销量' || c.field === '30天最高销量' || c.field === '14天最低销量' || c.field === '14天标准差') {
return (raw === 0 || raw === null || raw === undefined) ? '0' : String(raw);
}
if (c.field === '库存' || c.field === '加工厂库存' || c.field === '生产在途' || c.field === '采购在途') {
return (raw === 0 || raw === null || raw === undefined) ? '0' : String(raw);
}
if (c.field === 'cost_price') {
var val = parseFloat(raw);
return isNaN(val) ? '-' : '¥' + val.toFixed(2);
}
if (c.field === '最低销量偏差率' || c.field === '最高销量偏差率' || c.field === '14天波动率') {
return (raw === 0 || raw === null || raw === undefined) ? '0' : String(raw);
}
if (c.field === '极值判断') {
var cls = 'tag-grey';
if (raw === '极大/极小') cls = 'tag-red';
else if (raw === '极小') cls = 'tag-orange';
else if (raw === '极大') cls = 'tag-orange';
else if (raw === '正常') cls = 'tag-green';
return '<span class="tag ' + cls + '">' + (raw || '-') + '</span>';
}
if (c.field === '波动风险') {
var cls = 'tag-blue';
if (raw === 'X') cls = 'tag-green';
else if (raw === 'Y') cls = 'tag-orange';
else if (raw === 'Z') cls = 'tag-red';
return '<span class="tag ' + cls + '">' + (raw || '-') + '</span>';
}
if (c.field === '产品分级') {
var gradeColors = {'S级':'tag-red', 'A级':'tag-orange', 'B级':'tag-blue', 'C级':'tag-grey', '不参与评级':'tag-grey'};
var cls = gradeColors[raw] || 'tag-grey';
return '<span class="tag ' + cls + '" style="font-weight:700">' + (raw || '-') + '</span>';
}
if (c.field === '销售趋势') {
var trendColors = {
'开始上升': 'tag-blue', '开始下跌': 'tag-purple',
'平稳趋势': 'tag-green', '上升趋稳': 'tag-blue',
'下跌趋稳': 'tag-purple', '持续上升': 'tag-blue',
'急跌': 'tag-red', '持续下跌': 'tag-red',
'急增': 'tag-orange', '近期无销售': 'tag-grey'
};
var cls = trendColors[raw] || 'tag-grey';
return '<span class="tag ' + cls + '">' + (raw || '-') + '</span>';
}
if (c.field === '是否新品') {
return (raw === '新品') ? '<span class="tag tag-blue">新品</span>' : '<span class="tag tag-grey">返单品</span>';
}
if (c.field === '日销量预测') {
var val = parseFloat(raw);
if (isNaN(val) || val === 0) return '0';
return '<span style="font-weight:600;color:var(--primary)">' + Math.round(val) + '</span>';
}
if (c.field === '库存可支撑天数') {
var val = parseFloat(raw);
if (isNaN(val)) return '-';
if (val === 999) return '<span class="tag tag-grey">999</span>';
return val.toFixed(1);
}
if (c.field === '库存工厂可支撑天数') {
var val = parseFloat(raw);
if (isNaN(val)) return '-';
if (val === 999) return '<span class="tag tag-grey">999</span>';
return val.toFixed(1);
}
if (c.field === '加工厂库存支撑天数') {
var val = parseFloat(raw);
if (isNaN(val)) return '-';
if (val === 999) return '<span class="tag tag-grey">999</span>';
return val.toFixed(1);
}
if (c.field === '库存工厂待生产可支撑天数') {
var val = parseFloat(raw);
if (isNaN(val)) return '-';
if (val === 999) return '<span class="tag tag-grey">999</span>';
return val.toFixed(1);
}
if (c.field === '总支撑天数') {
var val = parseFloat(raw);
if (isNaN(val)) return '-';
if (val === 999) return '<span class="tag tag-grey">999</span>';
return val.toFixed(1);
}
if (c.field === '库存健康指数') {
var val = parseFloat(raw);
if (isNaN(val) || val === 999) return '<span class="tag tag-grey">999</span>';
if (val >= 1.5) return '<span class="tag tag-green">' + val.toFixed(1) + '</span>';
if (val >= 0.8) return '<span class="tag tag-orange">' + val.toFixed(1) + '</span>';
return '<span class="tag tag-red">' + val.toFixed(1) + '</span>';
}
if (c.field === '风险把控') {
var riskColors = {'正常':'tag-green','新品':'tag-blue','呆滞品':'tag-orange','需启动清库存':'tag-red'};
var cls = riskColors[raw] || 'tag-grey';
return '<span class="tag ' + cls + '">' + (raw || '-') + '</span>';
}
if (c.field === '建议下单数量') {
var val = parseInt(raw);
if (isNaN(val) || val === 0) return '0';
return '<span style="font-weight:600;color:var(--primary)">' + val + '</span>';
}
if (c.field === '实际下单数量') {
var val = parseInt(raw);
if (isNaN(val) || val === 0) return '0';
return '<span style="font-weight:600;color:#d4380d">' + val + '</span>';
}
if (c.field === '正式下单数量') {
var currentVal = parseInt(r['实际下单数量']) || 0;
var code = escHtml(r.product_code || '');
return '<span class="formal-qty-wrap" style="display:inline-flex;align-items:center;gap:4px">' +
'<span class="formal-qty-val" data-code="' + code + '" data-val="' + currentVal + '" id="fqv-' + code + '">' + currentVal + '</span>' +
'<span class="formal-qty-edit" onclick="editFormalQty(\'' + code + '\')" style="cursor:pointer;color:var(--primary);font-size:14px" title="编辑">✏️</span>' +
'</span>';
}
if (c.field === '建议回货数量') {
var val = parseFloat(raw);
if (isNaN(val) || val === 0) return '0';
return '<span style="font-weight:600;color:#cf1322">' + val.toFixed(1) + '</span>';
}
// 人员列:彩色标签
if (c.field === '采购跟单' || c.field === '采购计划' || c.field === '开发专员' || c.field === '运营专员') {
return renderNameBadges(raw);
}
return escHtml(raw || '-');
});
// 冻结列:计算可见冻结列的 left 偏移
// 冻结列:计算可见冻结列的 left 偏移
var colLefts = {};
if (planFrozen) {
var acc = 86;
colLefts['_cb'] = 0;
colLefts['_index'] = 36;
planFrozenFields.forEach(function(f) {
if (window.planColVisible[f]) {
colLefts[f] = acc;
acc += (FROZEN_WIDTHS[f] || 100);
}
});
}
var cbSticky = planFrozen ? 'position:sticky;left:0;z-index:9;background:var(--bg-card);transform:translateZ(0);min-width:36px;text-align:center' : 'text-align:center';
var idxSticky = planFrozen ? 'position:sticky;left:36px;z-index:9;background:var(--bg-card);transform:translateZ(0);min-width:50px' : '';
var code2 = r.product_code || '';
var isChecked = window._planSelectedProductCodes && window._planSelectedProductCodes[code2] ? ' checked' : '';
let colsHtml = `<td class="plan-col-cb" style="${cbSticky}"><input type="checkbox" class="plan-row-cb" value="${escHtml(code2)}"${isChecked} onchange="planUpdateSelectAll()"></td>`;
colsHtml += `<td class="plan-col-index row-index" style="text-align:center;width:50px;${idxSticky}">${startIdx + rowIdx + 1}</td>`;
colsHtml += PLAN_COLUMNS.map((c, i) => {
const disp = window.planColVisible[c.field] ? '' : ' style="display:none"';
var extraStyle = '';
if (planFrozen && colLefts[c.field] !== undefined) {
extraStyle = 'position:sticky;left:' + colLefts[c.field] + 'px;z-index:9;background:var(--bg-card);transform:translateZ(0);min-width:' + (FROZEN_WIDTHS[c.field] || 100) + 'px';
}
return `<td class="plan-col-${i}"${disp} style="${extraStyle}">${vals[i]}</td>`;
}).join('');
var code = r.product_code || '';
// 序列化下单所需数据
var formalQty = parseInt(r['实际下单数量']) || 0;
var suggestedQty = parseInt(r['建议下单数量']) || 0;
var orderData = JSON.stringify({
product_code: r.product_code || '',
product_name: r.product_name || '',
product_type: r.product_type || '',
product_grade: r['产品分级'] || '',
supplier_name: r['供应商名称'] || '',
lead_time: r['生产货期'] || 0,
logistics_time: r['物流时间'] || 0,
total_support_days: r['总支撑天数'] || 0,
procurement_follower: r['采购跟单'] || '',
procurement_planner: r['采购计划'] || '',
platform: r['归属平台'] || '',
operator: r['运营专员'] || '',
formal_order_qty: formalQty,
suggested_qty: suggestedQty
});
colsHtml += '<td class="plan-col-action" style="position:sticky;right:0;z-index:1;background:var(--bg,#fff);border-left:1px solid var(--border);white-space:nowrap">';
colsHtml += '<button class="btn btn-sm btn-outline" onclick="showProductDetail(\'' + escHtml(code) + '\')" title="展开详情">详情</button> ';
colsHtml += '<button class="btn btn-sm btn-outline" onclick="showProductDetailEdit(\'' + escHtml(code) + '\')" title="编辑">编辑</button>';
if (!window._planOrderDataCache) window._planOrderDataCache = {};
window._planOrderDataCache[code] = orderData;
colsHtml += '<button class="btn btn-sm" style="background:#e6fffb;color:#13c2c2;border:1px solid #87e8de" onclick="planOrder(\'' + escHtml(code) + '\')" title="下单">📦 下单</button>';
colsHtml += '<button class="btn btn-sm btn-outline-danger" onclick="planDeleteProduct(\'' + escHtml(code) + '\')" title="从计划预测中移除" style="margin-left:4px;font-size:11px">删除</button>';
colsHtml += '</td>';
return `<tr>${colsHtml}</tr>`;
}).join('');
// 渲染完成后动态修正冻结列位置(实际列宽与 FROZEN_WIDTHS 有偏差时修正)
// 更新表头排序箭头状态(在冻结列之前,因为 buildPlanTableHeader 会替换 thead DOM)
var planTbl = document.getElementById('planTable');
if (planTbl) buildPlanTableHeader(planTbl);
// 在表头重建后重新应用筛选图标
setTimeout(function() { if (typeof _restoreFilterIcons === 'function') _restoreFilterIcons(); }, 50);
// 渲染完成后动态修正冻结列位置(实际列宽与 FROZEN_WIDTHS 有偏差时修正)
updatePlanFrozenPositions();
// 更新选中 UI
if (typeof planSyncSelectUI === 'function') planSyncSelectUI();
// 更新筛选图标状态
if (typeof _restoreFilterIcons === 'function') _restoreFilterIcons();
}
/**
* 动态计算冻结列正确的 left 偏移(基于实际渲染宽度,而非固定 FROZEN_WIDTHS)
*/
function updatePlanFrozenPositions() {
if (!planFrozen) return;
var table = document.getElementById('planTable');
if (!table) return;
var headerRow = table.querySelector('thead tr');
if (!headerRow) return;
// 获取所有可见冻结列的实际宽度
var actualWidths = {};
// 勾选列 + 序号列
var cbTh = headerRow.querySelector('.plan-col-cb');
if (cbTh) actualWidths['_cb'] = cbTh.offsetWidth;
var indexTh = headerRow.querySelector('.plan-col-index');
if (indexTh) actualWidths['_index'] = indexTh.offsetWidth;
planFrozenFields.forEach(function(field) {
var idx = window['_planColIdx_' + field];
if (idx === undefined) return;
var th = headerRow.querySelector('.plan-col-' + idx + '.plan-th-' + idx);
if (th && window.planColVisible[field]) {
actualWidths[field] = th.offsetWidth;
}
});
// 用实际宽度重新计算 left 偏移,并应用到所有 th 和 td
var acc = 0;
// 勾选列
if (cbTh && actualWidths['_cb']) {
cbTh.style.position = 'sticky';
cbTh.style.top = '0';
cbTh.style.left = '0px';
cbTh.style.zIndex = '999';
cbTh.style.background = 'var(--bg-card)';
acc += actualWidths['_cb'];
} else {
acc += 36;
}
// 序号列
if (indexTh && actualWidths['_index']) {
indexTh.style.position = 'sticky';
indexTh.style.top = '0';
indexTh.style.left = acc + 'px';
indexTh.style.zIndex = '999';
indexTh.style.background = 'var(--bg-card)';
acc += actualWidths['_index'];
} else {
acc += 50;
}
planFrozenFields.forEach(function(field) {
if (!window.planColVisible[field]) return;
var idx = window['_planColIdx_' + field];
if (idx === undefined) return;
var leftVal = acc + 'px';
// 更新表头 th(必须同时设 position/top/z-index,否则可能因 buildPlanTableHeader 而被抹掉)
headerRow.querySelectorAll('.plan-col-' + idx + '.plan-th-' + idx).forEach(function(el) {
el.style.position = 'sticky';
el.style.top = '0';
el.style.left = leftVal;
el.style.zIndex = '999';
el.style.background = 'var(--bg-card)';
});
// 更新所有数据行中的 td
table.querySelectorAll('tbody .plan-col-' + idx).forEach(function(el) {
el.style.position = 'sticky';
el.style.left = leftVal;
el.style.zIndex = '9';
el.style.background = 'var(--bg-card)';
});
acc += actualWidths[field] || FROZEN_WIDTHS[field] || 100;
});
// 第二次修正:延迟确保渲染完成后重新计算(解决首次加载时可能计算不准)
setTimeout(function() {
// 重新测量实际宽度
var widths2 = {};
var cbTh2 = headerRow.querySelector('.plan-col-cb');
if (cbTh2) widths2['_cb'] = cbTh2.offsetWidth;
var idxTh2 = headerRow.querySelector('.plan-col-index');
if (idxTh2) widths2['_index'] = idxTh2.offsetWidth;
planFrozenFields.forEach(function(field) {
if (!window.planColVisible[field]) return;
var idx = window['_planColIdx_' + field];
if (idx === undefined) return;
var th = headerRow.querySelector('.plan-col-' + idx + '.plan-th-' + idx);
if (th) widths2[field] = th.offsetWidth;
});
var acc2 = widths2['_cb'] || 36;
if (cbTh2) {
cbTh2.style.left = '0px';
table.querySelectorAll('tbody .plan-col-cb').forEach(function(el) {
el.style.position = 'sticky';
el.style.left = '0px';
el.style.zIndex = '9';
el.style.background = 'var(--bg-card)';
});
}
acc2 += widths2['_index'] || 50;
if (idxTh2) {
idxTh2.style.left = (widths2['_cb'] || 36) + 'px';
table.querySelectorAll('tbody .plan-col-index').forEach(function(el) {
el.style.left = (widths2['_cb'] || 36) + 'px';
});
}
planFrozenFields.forEach(function(field) {
if (!window.planColVisible[field]) return;
var idx = window['_planColIdx_' + field];
if (idx === undefined) return;
var lv = acc2 + 'px';
headerRow.querySelectorAll('.plan-col-' + idx + '.plan-th-' + idx).forEach(function(el) {
el.style.left = lv;
});
table.querySelectorAll('tbody .plan-col-' + idx).forEach(function(el) {
el.style.left = lv;
});
acc2 += widths2[field] || FROZEN_WIDTHS[field] || 100;
});
}, 50);
}
// ─── 冻结列弹窗 ───
function openPlanFrozenModal() {
if (window._planFrozenPendingFields === undefined) {
window._planFrozenPendingFields = planFrozenFields.slice();
}
var html = '<div style="padding:8px 0">';
html += '<label style="display:flex;align-items:center;gap:8px;padding:6px 0;cursor:pointer">';
html += '<input type="checkbox" id="planFrozenCbAll" checked onchange="planFrozenToggleAll(this.checked)"> <strong>全选/取消</strong>';
html += '</label><div style="border-top:1px solid var(--border);padding-top:8px;margin-top:4px">';
var candidates = [
{field:'_index', label:'序号'},
{field:'product_type', label:'产品类型'},
{field:'product_code', label:'货品编号'},
{field:'product_name', label:'货品名称'},
{field:'产品分级', label:'产品分级'}
];
candidates.forEach(function(c) {
var checked = window._planFrozenPendingFields.indexOf(c.field) >= 0;
html += '<label style="display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer">';
html += '<input type="checkbox" class="plan-frozen-cb" value="' + c.field + '"' + (checked ? ' checked' : '') + ' onchange="planFrozenCbChange()">';
html += '<span>' + c.label + '</span></label>';
});
html += '</div></div>';
document.getElementById('planFrozenModalBody').innerHTML = html;
document.getElementById('planFrozenModal').style.display = 'flex';
}
window.planFrozenCbChange = function() {
var checked = [];
document.querySelectorAll('.plan-frozen-cb:checked').forEach(function(cb) { checked.push(cb.value); });
window._planFrozenPendingFields = checked;
var allCb = document.getElementById('planFrozenCbAll');
if (allCb) allCb.checked = checked.length === document.querySelectorAll('.plan-frozen-cb').length;
};
window.planFrozenToggleAll = function(checked) {
document.querySelectorAll('.plan-frozen-cb').forEach(function(cb) { cb.checked = checked; });
planFrozenCbChange();
};
function closePlanFrozenModal() {
document.getElementById('planFrozenModal').style.display = 'none';
}
function confirmPlanFrozen() {
planFrozenFields = window._planFrozenPendingFields.slice();
planFrozen = planFrozenFields.length > 0;
var page = 'planning';
if (window._pageCache && window._pageCache[page]) delete window._pageCache[page];
loadPage(page);
}
// ─── 表头筛选菜单 ───
var _planFilterMenuField = '';
// ─── Excel风格表头筛选 ───
var _planFilterMenuField = '';
var _planFilterSelected = {};
function openPlanFilterMenu(field, event) {
event = event || window.event;
event.stopPropagation();
var existing = document.getElementById('planFilterMenu');
if (existing) existing.remove();
_planFilterMenuField = field;
var th = event.currentTarget;
var rect = th.getBoundingClientRect();
// 跟随表头滚动:监听滚动事件更新菜单位置
document.addEventListener('scroll', planFilterScrollHandler, true);
var label = '';
PLAN_COLUMNS.forEach(function(c) { if (c.field === field) label = c.label; });
// 判断是否为数字列
var isNumericCol = SORTABLE_FIELDS[field] || field === '产品分级';
var menu = null;
if (isNumericCol) {
menu = document.createElement('div');
menu.id = 'planFilterMenu';
var isSorted = planSortField === field;
var currentOrder = isSorted ? (planSortAsc ? 'asc' : 'desc') : '';
_buildNumericFilterMenu(field, label, rect, isSorted, currentOrder, menu);
} else {
// 获取该列所有唯一值
var values = [];
var predefined = PLAN_FILTER_OPTIONS[field];
if (predefined) {
values = predefined.slice();
} else {
var seen = {};
var rows = document.querySelectorAll('#planTbody tr');
rows.forEach(function(tr) {
var idx = -1;
PLAN_COLUMNS.forEach(function(c, i) { if (c.field === field) idx = i; });
if (idx >= 0) {
var td = tr.querySelector('.plan-col-' + idx);
if (td) {
var txt = td.textContent.trim();
if (txt && !seen[txt]) {
seen[txt] = true;
values.push(txt);
}
}
}
});
values.sort();
}
if (values.length > 100) values = values.slice(0, 100);
if (!_planFilterSelected[field]) _planFilterSelected[field] = {};
var isSorted = planSortField === field;
var currentOrder = isSorted ? (planSortAsc ? 'asc' : 'desc') : '';
menu = document.createElement('div');
menu.id = 'planFilterMenu';
menu.style.cssText = 'position:fixed;top:' + (rect.bottom + 4) + 'px;left:' + Math.max(rect.left, 10) + 'px;z-index:10001;background:#fff;border:1px solid #d9d9d9;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.15);padding:10px 12px;min-width:220px;max-width:320px;max-height:400px;font-size:13px;display:flex;flex-direction:column';
var html = '';
html += '<div style="font-weight:600;margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid #f0f0f0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + escHtml(label) + '</div>';
html += '<div style="margin-bottom:6px"><input type="text" id="planFilterSearch" class="form-input" style="width:100%;box-sizing:border-box;font-size:12px" placeholder="搜索..." autocomplete="off" oninput="planFilterSearchList(this.value)"></div>';
html += '<div style="margin-bottom:6px;display:flex;gap:4px">';
html += '<button class="btn btn-sm ' + (currentOrder === 'asc' ? 'btn-primary' : 'btn-outline') + '" onclick="planFilterSort(\'' + field + '\', \'asc\')" style="font-size:11px;padding:2px 6px">▲ 升序</button>' +
'<button class="btn btn-sm ' + (currentOrder === 'desc' ? 'btn-primary' : 'btn-outline') + '" onclick="planFilterSort(\'' + field + '\', \'desc\')" style="font-size:11px;padding:2px 6px">▼ 降序</button>';
html += '</div>';
html += '<div style="margin-bottom:4px;display:flex;gap:8px;font-size:12px">';
html += '<label style="cursor:pointer;display:flex;align-items:center;gap:3px"><input type="checkbox" id="planFilterSelectAll" checked onchange="planFilterToggleAll(this.checked)"> <strong>全选</strong></label>';
html += '<a href="javascript:;" onclick="planFilterClear()" style="color:var(--text-light);font-size:12px">清除筛选</a></div>';
html += '<div id="planFilterList" style="overflow-y:auto;flex:1;max-height:200px;border-top:1px solid #f0f0f0;padding-top:4px">';
values.forEach(function(v) {
var key = escHtml(v).replace(/'/g, "\\'");
var checked = _planFilterSelected[field][v] !== false ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:4px;padding:3px 2px;cursor:pointer;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">';
html += '<input type="checkbox" class="plan-filter-cb" value="' + escHtml(v) + '" ' + checked + ' onchange="planFilterCbChange()"> ' + escHtml(v);
html += '</label>';
});
html += '</div>';
html += '<div style="margin-top:6px;padding-top:6px;border-top:1px solid #f0f0f0;display:flex;justify-content:flex-end;gap:6px">';
html += '<button class="btn btn-sm btn-primary" onclick="planFilterApply()" style="font-size:12px;padding:4px 12px">确定</button><button class="btn btn-sm" onclick="planFilterResetAll()" style="font-size:12px;padding:4px 10px;margin-left:6px">重置</button></div>';
menu.innerHTML = html;
document.body.appendChild(menu);
}
// 搜索自动聚焦
setTimeout(function() {
var inp = document.getElementById('planFilterSearch');
if (inp) inp.focus();
}, 150);
// 点击外部关闭
var handler = function(e) {
if (!menu.contains(e.target) && e.target !== th && !th.contains(e.target)) {
menu.remove();
document.removeEventListener('click', handler);
}
};
setTimeout(function() { document.addEventListener('click', handler); }, 10);
}
// ─── 数字列条件筛选 ───
function _buildNumericFilterMenu(field, label, rect, isSorted, currentOrder, menu) {
var html = '';
html += '<div style="font-weight:600;margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid #f0f0f0">' + escHtml(label) + '</div>';
// 排序
html += '<div style="margin-bottom:6px;display:flex;gap:4px">';
html += '<button class="btn btn-sm ' + (currentOrder === 'asc' ? 'btn-primary' : 'btn-outline') + '" onclick="planFilterSort(\'' + field + '\', \'asc\')" style="font-size:11px;padding:2px 6px">▲ 升序</button>' +
'<button class="btn btn-sm ' + (currentOrder === 'desc' ? 'btn-primary' : 'btn-outline') + '" onclick="planFilterSort(\'' + field + '\', \'desc\')" style="font-size:11px;padding:2px 6px">▼ 降序</button>';
html += '</div>';
// 条件选择
html += '<div style="margin-bottom:6px">';
html += '<select id="planNumCond" class="form-input" style="width:100%;box-sizing:border-box;font-size:12px">';
html += '<option value="">-- 选择条件 --</option>';
html += '<option value="eq">等于</option>';
html += '<option value="neq">不等于</option>';
html += '<option value="gt">大于</option>';
html += '<option value="gte">大于等于</option>';
html += '<option value="lt">小于</option>';
html += '<option value="lte">小于等于</option>';
html += '<option value="range">介于(逗号分隔)</option>';
html += '</select>';
html += '</div>';
// 数值输入
html += '<div style="margin-bottom:6px">';
html += '<input type="text" id="planNumVal" class="form-input" style="width:100%;box-sizing:border-box;font-size:12px" placeholder="输入数值..." autocomplete="off" onkeydown="if(event.key===\'Enter\')planNumFilterApply()">';
html += '</div>';
// 底部按钮
html += '<div style="border-top:1px solid #f0f0f0;padding-top:6px;display:flex;justify-content:flex-end;gap:6px">';
html += '<button class="btn btn-sm" onclick="planFilterClear()" style="font-size:12px;padding:4px 10px">清除</button>';
html += '<button class="btn btn-sm btn-primary" onclick="planNumFilterApply()" style="font-size:12px;padding:4px 12px">确定</button>';
html += '<button class="btn btn-sm" onclick="planFilterResetAll()" style="font-size:12px;padding:4px 10px;margin-left:6px">重置</button>';
html += '</div>';
menu.innerHTML = html;
menu.style.cssText = 'position:fixed;top:' + (rect.bottom + 4) + 'px;left:' + Math.max(rect.left, 10) + 'px;z-index:10001;background:#fff;border:1px solid #d9d9d9;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.15);padding:10px 12px;min-width:200px;font-size:13px';
document.body.appendChild(menu);
setTimeout(function() { var inp = document.getElementById('planNumVal'); if (inp) inp.focus(); }, 100);
}
function planNumFilterApply() {
var field = _planFilterMenuField;
var cond = document.getElementById('planNumCond')?.value || '';
var val = document.getElementById('planNumVal')?.value || '';
if (!cond || !val) {
alert('请选择条件并输入数值');
return;
}
// 保存到全局筛选状态
if (!window._planFilters) window._planFilters = {};
window._planFilters[field] = {type:'num', cond: cond, val: val};
_syncFiltersToUI();
closePlanFilterMenu();
loadPlanPage(1);
}
function planFilterApply() {
var field = _planFilterMenuField;
var cbs = document.querySelectorAll('.plan-filter-cb:checked');
var checked = [];
cbs.forEach(function(cb) { checked.push(cb.value); });
if (!window._planFilters) window._planFilters = {};
if (checked.length === 0 || checked.length === document.querySelectorAll('.plan-filter-cb').length) {
// 全选或没选 → 清除该列筛选
delete window._planFilters[field];
} else {
window._planFilters[field] = {type:'list', values: checked};
}
_syncFiltersToUI();
closePlanFilterMenu();
loadPlanPage(1);
}
function _syncFiltersToUI() {
// 仅用于更新表头筛选图标状态
var activeFilters = [];
Object.keys(window._planFilters || {}).forEach(function(f) {
var filter = window._planFilters[f];
if (filter) { filter.field = f; activeFilters.push(filter); }
});
_updateFilterIcons(activeFilters);
}
function _updateFilterIcons(activeFilters) {
var activeFields = {};
activeFilters.forEach(function(f) { activeFields[f.field] = true; });
PLAN_COLUMNS.forEach(function(c, i) {
var th = document.querySelector('.plan-th-' + i);
if (th) {
var isActive = activeFields[c.field];
var icon = th.querySelector('span[title="筛选"]');
if (icon) {
icon.style.color = isActive ? '#1890ff' : 'var(--text-light)';
icon.style.fontWeight = isActive ? 'bold' : 'normal';
icon.textContent = isActive ? '▼' : '▾';
}
}
});
}
function _restoreFilterIcons() {
var activeFilters = [];
Object.keys(window._planFilters || {}).forEach(function(f) {
var fp = window._planFilters[f];
if (fp) { fp.field = f; activeFilters.push(fp); }
});
_updateFilterIcons(activeFilters);
}
function planFilterSearchList(q) {
var items = document.querySelectorAll('#planFilterList label');
q = (q || '').toLowerCase();
items.forEach(function(label) {
var txt = label.textContent.toLowerCase();
label.style.display = (!q || txt.indexOf(q) >= 0) ? '' : 'none';
});
}
function planFilterToggleAll(checked) {
document.querySelectorAll('.plan-filter-cb').forEach(function(cb) { cb.checked = checked; });
}
function planFilterCbChange() {
var all = document.querySelectorAll('.plan-filter-cb');
var checked = document.querySelectorAll('.plan-filter-cb:checked');
var cb = document.getElementById('planFilterSelectAll');
if (cb) {
cb.checked = all.length === checked.length;
cb.indeterminate = checked.length > 0 && checked.length < all.length;
}
}
function planFilterSort(field, order) {
if (order === 'asc') { planSortField = field; planSortAsc = true; }
else if (order === 'desc') { planSortField = field; planSortAsc = false; }
else { planSortField = ''; }
closePlanFilterMenu();
loadPlanPage(1);
}
function planFilterClear() {
var field = _planFilterMenuField;
if (window._planFilters) delete window._planFilters[field];
_syncFiltersToUI();
closePlanFilterMenu();
loadPlanPage(1);
}
// 重置所有筛选条件
window.planFilterResetAll = function() {
window._planFilters = {};
window._planFilterSelected = {};
planSortField = '';
planSortAsc = true;
// 清空主搜索框和下拉筛选
['planSearch','planSupplierFilter','planFollowerFilter','planPlannerFilter','planDateCond','planDateVal'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
_syncFiltersToUI();
closePlanFilterMenu();
loadPlanPage(1);
};
function closePlanFilterMenu() {
var m = document.getElementById('planFilterMenu');
if (m) m.remove();
document.removeEventListener('scroll', planFilterScrollHandler, true);
}
var planFilterScrollHandler = function() {
var menu = document.getElementById('planFilterMenu');
if (!menu) return;
var colIdx = window['_planColIdx_' + _planFilterMenuField];
if (colIdx === undefined) return;
var th = document.querySelector('.plan-th-' + colIdx);
if (th) {
var r = th.getBoundingClientRect();
menu.style.top = (r.bottom + 4) + 'px';
menu.style.left = Math.max(r.left, 10) + 'px';
}
};
function togglePlanSort(field) {
if (planSortField === field) {
planSortAsc = !planSortAsc;
} else {
planSortField = field;
planSortAsc = true;
}
loadPlanPage(1);
}
function searchPlanning() { loadPlanPage(1); }
var planActiveView = 'default';
function switchPlanView(view) {
planActiveView = view;
document.querySelectorAll('.plan-tab').forEach(function(el) {
el.classList.toggle('active', el.dataset.view === view);
});
// 切换视图时重置筛选条件并加载
resetPlanningFiltersNoSearch();
loadPlanPage(1);
}
function resetPlanningFiltersNoSearch() {
document.getElementById('planSearch').value = '';
document.getElementById('planDateCond').value = '';
document.getElementById('planDateVal').value = '';
document.getElementById('planSupplierFilter').value = '';
document.getElementById('planFollowerFilter').value = '';
document.getElementById('planPlannerFilter').value = '';
window._planFilters = {};
setupFilterClearButtons();
}
// 从数据行中提取采购跟单/采购计划去重值,填充下拉选项
function populatePlanPersonFilter(rows) {
function getDistinct(field) {
var set = new Set();
(rows || []).forEach(function(r) {
var v = r[field];
if (v && v !== '-' && v.trim()) {
v.split(',').forEach(function(name) {
name = name.trim();
if (name) set.add(name);
});
}
});
return Array.from(set).sort();
}
['planFollowerFilter', 'planPlannerFilter'].forEach(function(elId) {
var sel = document.getElementById(elId);
if (!sel) return;
var currentVal = sel.value;
var field = elId === 'planFollowerFilter' ? '采购跟单' : '采购计划';
var label = elId === 'planFollowerFilter' ? '采购跟单' : '采购计划';
var names = getDistinct(field);
sel.innerHTML = '<option value="">全部' + label + '</option>' +
names.map(function(n) { return '<option value="' + escHtml(n) + '">' + escHtml(n) + '</option>'; }).join('');
sel.value = currentVal;
});
}
function resetPlanningFilters() {
document.getElementById('planSearch').value = '';
document.getElementById('planDateCond').value = '';
document.getElementById('planDateVal').value = '';
document.getElementById('planSupplierFilter').value = '';
document.getElementById('planFollowerFilter').value = '';
document.getElementById('planPlannerFilter').value = '';
// 清除所有列筛选状态
window._planFilters = {};
// 更新清除按钮状态
setupFilterClearButtons();
loadPlanPage(1);
}
// ─── 列方案管理 ───
// 构建当前列配置JSON
function buildPlanColumnsJSON() {
var order = window._planColDragOrder || PLAN_COLUMNS.map(function(c) { return c.field; });
var cols = [];
order.forEach(function(field) {
var colDef = null;
PLAN_COLUMNS.forEach(function(c) {
if (c.field === field) colDef = c;
});
if (colDef) {
cols.push({
field: colDef.field,
label: colDef.label,
visible: window._planPendingVisible[field] !== undefined ? window._planPendingVisible[field] : true
});
}
});
PLAN_COLUMNS.forEach(function(c) {
var found = false;
cols.forEach(function(col) { if (col.field === c.field) found = true; });
if (!found) {
cols.push({
field: c.field,
label: c.label,
visible: window._planPendingVisible[c.field] !== undefined ? window._planPendingVisible[c.field] : c.defaultShow
});
}
});
return JSON.stringify(cols);
}
// 保存方案
window.savePlanScheme = function() {
var name = document.getElementById('planSchemeNameInput');
if (!name) return;
var schemeName = name.value.trim();
if (!schemeName) {
alert('请输入方案名称');
name.focus();
return;
}
var columnsJson = buildPlanColumnsJSON();
fetch('/api/planning/scheme/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
scheme_name: schemeName,
columns_json: columnsJson,
page: 'planning',
is_default: 0
})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
name.value = '';
refreshPlanSchemeList();
} else {
alert(res.msg || '保存失败');
}
})
.catch(function() { alert('保存失败,请检查网络'); });
};
// 加载方案并预览(在弹窗中勾选对应字段,不关闭弹窗)
window.loadPlanScheme = function(schemeId) {
if (schemeId === undefined || schemeId === null) return;
fetch('/api/planning/schemes')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
var schemes = res.data || [];
var target = null;
for (var i = 0; i < schemes.length; i++) {
if (schemes[i].id == schemeId) {
target = schemes[i];
break;
}
}
if (!target) {
alert('方案不存在或已被删除');
refreshPlanSchemeList();
return;
}
var cols;
try {
cols = JSON.parse(target.columns_json);
} catch(e) {
alert('方案数据异常');
return;
}
if (!cols || !Array.isArray(cols) || cols.length === 0) {
alert('方案数据为空');
return;
}
// 只更新临时状态(弹窗内的勾选),不关闭弹窗
var newOrder = [];
var pendingVisible = {};
cols.forEach(function(c) {
if (c && c.field) {
newOrder.push(c.field);
pendingVisible[c.field] = c.visible !== false;
}
});
PLAN_COLUMNS.forEach(function(c) {
if (pendingVisible[c.field] === undefined) {
newOrder.push(c.field);
pendingVisible[c.field] = false;
}
});
// 更新临时勾选状态和拖拽顺序
window._planColDragOrder = newOrder.slice();
Object.keys(pendingVisible).forEach(function(field) {
window._planPendingVisible[field] = pendingVisible[field];
});
window._planPendingVisible = pendingVisible;
// 刷新弹窗内的 checkbox 状态
var grid = document.getElementById('planColGrid');
if (grid) renderPlanColCheckboxes(grid);
// 同步全选状态
var allCb = document.getElementById('plan-cb-all');
if (allCb) {
var allChecked = true;
PLAN_COLUMNS.forEach(function(c) {
if (!window._planPendingVisible[c.field]) allChecked = false;
});
allCb.checked = allChecked;
}
})
.catch(function(err) {
console.error('loadPlanScheme error:', err);
});
};
// 构建表头
function buildPlanTableHeader(planTable) {
var newThead = '<tr><th class="plan-col-cb" style="width:36px;min-width:36px;text-align:center"><input type="checkbox" id="planSelectAllCb" onchange="planToggleCurrentPage(this.checked)" title="全选当前页"></th><th class="plan-col-index" style="width:50px"><div style="text-align:center;width:100%">序号</div></th>';
PLAN_COLUMNS.forEach(function(c, i) {
var disp = window.planColVisible[c.field] ? '' : ' style="display:none"';
var isNumericSortable = (c.field === '产品分级') || SORTABLE_FIELDS[c.field];
var sortArrowHtml = '';
if (isNumericSortable) {
var isSorted = planSortField === c.field;
var upActive = isSorted && planSortAsc;
var downActive = isSorted && !planSortAsc;
var upStyle = upActive ? 'color:#cf1322;font-weight:700;font-size:12px' : 'color:#d9d9d9;font-size:11px';
var downStyle = downActive ? 'color:#cf1322;font-weight:700;font-size:12px' : 'color:#d9d9d9;font-size:11px';
sortArrowHtml = ' <span style="font-size:11px"><span style="' + upStyle + '">▲</span><span style="' + downStyle + '">▼</span></span>';
}
var planTips = {'日销量预测':'日销量预测 = (3天平均×w3 + 7天平均×w7 + 14天平均×w14) × 调整系数','库存可支撑天数':'库存可支撑天数 = 库存 / 日销量预测,为0时显示999','加工厂库存支撑天数':'加工厂库存支撑 = 加工厂库存 / 日销量预测,为0时显示999','库存工厂可支撑天数':'现货采购在途 = 采购在途 / 日销量预测','库存工厂待生产可支撑天数':'定制品在途 = 生产在途 / 日销量预测','总支撑天数':'总支撑天数 = (库存 + 加工厂库存 + 生产在途 + 采购在途) / 日销量预测','库存健康指数':'日销量预测>0:库存可支撑天数÷(货期+物流);=0:库存可支撑天数÷(货期+物流)','建议下单数量':'日销量预测=0且库存>0则0,否则(安全库存天数+货期+物流)×日销量预测','实际下单数量':'建议下单数量向上取整到1000','正式下单数量':'正式下单数量初始等于实际下单数量,可人工修改','建议回货数量':'ROUNDUP((日销量预测×回货天数-库存)×10)/10,上限在途总数','是否新品':'创建时间5个月以内为新品,否则返单品','成本价':'从产品信息匹配的成本价格','销售趋势':'基于3/7/14天日均销量判断的9种趋势类型'};
var tip = planTips[c.label] || '';
newThead += '<th class="plan-col-' + i + ' plan-th-' + i + '"' + disp + ' onclick="openPlanFilterMenu(\'' + escJs(c.field) + '\', event)" style="cursor:pointer"' + (tip ? ' title="' + tip.replace(/"/g,'&quot;') + '"' : '') + '>' + c.label + sortArrowHtml + ' <span style="margin-left:2px;font-size:11px;color:var(--text-light)">▾</span></th>';
});
newThead += '<th class="plan-col-action" style="width:200px;min-width:200px;position:sticky;top:0;right:0;background:var(--bg,#fff);z-index:11;border-left:1px solid var(--border)">操作</th>';
newThead += '</tr>';
planTable.querySelector('thead').innerHTML = newThead;
}
// 删除方案
window.deletePlanScheme = function(schemeId) {
if (!confirm('确定要删除此方案吗?')) return;
fetch('/api/planning/scheme/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: schemeId})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
refreshPlanSchemeList();
} else {
alert(res.msg || '删除失败');
}
});
};
// 设置默认方案
window.setDefaultPlanScheme = function(schemeId) {
fetch('/api/planning/scheme/set_default', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: schemeId, page: 'planning'})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
// 设置默认同时加载该方案的字段配置到弹窗
loadPlanScheme(schemeId);
refreshPlanSchemeList();
} else {
alert(res.msg || '设置失败');
}
})
.catch(function(err) {
console.error('setDefaultPlanScheme error:', err);
});
};
// 刷新方案列表UI
function refreshPlanSchemeList() {
var container = document.getElementById('planSchemeList');
if (!container) return;
fetch('/api/planning/schemes')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
var schemes = res.data || [];
if (schemes.length === 0) {
container.innerHTML = '<span style="color:var(--text-light);font-size:13px">暂无保存方案</span>';
return;
}
var html = '';
schemes.forEach(function(s) {
var isDefault = s.is_default == 1;
html += '<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:4px;font-size:13px;background:' + (isDefault ? '#e6f7ff' : '#f5f5f5') + ';border:1px solid ' + (isDefault ? '#1890ff' : '#e8e8e8') + '">';
html += '<span style="cursor:pointer" onclick="loadPlanScheme(' + s.id + ')">' + escHtml(s.scheme_name) + '</span>';
if (isDefault) {
html += '<span style="color:#1890ff;font-size:11px">★默认</span>';
}
html += '<span style="color:#bbb;font-size:14px;cursor:pointer;margin-left:2px" onclick="setDefaultPlanScheme(' + s.id + ')" title="设为默认">📌</span>';
html += '<span style="color:#bbb;font-size:14px;cursor:pointer" onclick="deletePlanScheme(' + s.id + ')" title="删除">✕</span>';
html += '</span>';
});
container.innerHTML = html;
});
}
// 加载默认方案(页面初始化时调用)
function loadDefaultPlanScheme() {
fetch('/api/planning/schemes')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
var schemes = res.data || [];
var defaultScheme = null;
schemes.forEach(function(s) {
if (s.is_default == 1) defaultScheme = s;
});
if (defaultScheme) {
window.loadPlanScheme(defaultScheme.id);
}
});
}
// ─── 采购订单-待下单管理 ───
var _pendingItems = [];
function loadPurchasePage(container) {
container.innerHTML = `
<div class="page-header">
<h1>待下单管理</h1>
</div>
<div class="card">
<div class="card-header" style="display:flex;align-items:center;gap:12px">
<h3>📋 待下单商品</h3>
<span style="font-size:13px;color:var(--text-light)" id="pendingCount"></span>
<span id="pendingSelectedCount" style="font-size:13px;color:var(--primary);font-weight:600;margin-left:8px"></span>
<div style="margin-left:auto;gap:8px">
<button class="btn btn-sm btn-outline" onclick="loadPendingList()">🔄 刷新</button>
<button class="btn btn-sm" id="pendingFrozenBtn" onclick="togglePendingFrozen()">🔒 冻结列</button>
<button class="btn btn-sm" onclick="openPendingColModal()">⚙ 字段设置</button>
<button class="btn btn-sm" style="background:#e6fffb;color:#13c2c2;border:1px solid #87e8de" onclick="pendingBatchConfirm()">📦 批量确认下单</button>
</div>
</div>
<div class="card-body" style="overflow-x:auto">
<table class="data-table" id="pendingTable">
<thead>
<tr>
<th style="width:30px;text-align:center"><input type="checkbox" id="pendingSelectAllCb" onchange="pendingToggleAll(this.checked)" title="全选"></th>
<th style="width:40px;text-align:center">序号</th>
<th>产品类型</th>
<th>货品编号</th>
<th>货品名称</th>
<th style="width:100px">确认下单</th>
<th>产品分级</th>
<th>供应商名称</th>
<th>建议下单数量</th>
<th>正式下单数量</th>
<th>下单备注</th>
<th>货期</th>
<th>物流</th>
<th>总支撑天数</th>
<th>平台</th>
<th>运营</th>
<th>审核价格</th>
<th>下单人员</th>
<th>下单时间</th>
<th>采购跟单</th>
<th>采购计划</th>
<th>加入时间</th>
<th class="col-action" style="width:140px;min-width:140px;position:sticky;right:0;top:0;background:var(--bg,#fff);z-index:11;border-left:1px solid var(--border)">操作</th>
</tr>
</thead>
<tbody id="pendingTbody"></tbody>
</table>
</div>
</div>
`;
loadPendingList();
// 设置缓存重载器:从缓存恢复时重新拉取最新数据
if (!window._pageReloaders) window._pageReloaders = {};
if (!window._pageDataCache) window._pageDataCache = {};
window._pageReloaders['purchase'] = function() { loadPendingList(); };
window._pageDataCache['purchase'] = { _ts: Date.now() };
}
function loadPendingList() {
var tbody = document.getElementById('pendingTbody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="23" style="text-align:center;padding:30px;color:var(--text-light)"><div class="loading-spinner"></div></td></tr>';
fetch('/api/purchase/pending_list')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
_pendingItems = res.data || [];
var countEl = document.getElementById('pendingCount');
if (countEl) countEl.textContent = '共 ' + _pendingItems.length + ' 条';
if (_pendingItems.length === 0) {
tbody.innerHTML = '<tr><td colspan="23" style="text-align:center;padding:60px;color:var(--text-light)">暂无待下单商品 ✅</td></tr>';
return;
}
tbody.innerHTML = _pendingItems.map(function(item, idx) {
var code = escHtml(item.product_code);
return '<tr>' +
'<td style="text-align:center"><input type="checkbox" class="pending-row-cb" value="' + code + '" onchange="pendingUpdateCount()"></td>' +
'<td style="text-align:center">' + (idx + 1) + '</td>' +
'<td>' + escHtml(item.product_type || '-') + '</td>' +
'<td><code>' + code + '</code></td>' +
'<td>' + escHtml(item.product_name || '-') + '</td>' +
'<td style="text-align:center"><button class="btn btn-sm" style="background:#e6fffb;color:#13c2c2;border:1px solid #87e8de" onclick="pendingConfirmOrder(\'' + code + '\')">✅ 确认下单</button></td>' +
'<td>' + escHtml(item.product_grade || '-') + '</td>' +
'<td>' + escHtml(item.supplier_name || '-') + '</td>' +
'<td style="font-weight:600;color:var(--primary)">' + (item.suggested_qty || 0) + '</td>' +
'<td><input type="number" class="form-input" style="width:80px" value="' + (item.formal_order_qty || 0) + '" onchange="updateFormalQty(\'' + code + '\', this.value)"></td>' +
'<td><input type="text" class="form-input" style="width:130px" value="' + escHtml(item.remark || '') + '" data-pcode="' + code + '" onchange="savePendingRemark(this)" onkeydown="remarkEnterSave(event)" placeholder="下单备注"></td>' +
'<td>' + (item.lead_time || 0) + '</td>' +
'<td>' + (item.logistics_time || 0) + '</td>' +
'<td>' + (item.total_support_days || 0) + '</td>' +
'<td>' + escHtml(item.platform || '-') + '</td>' +
'<td>' + renderNameBadges(item.operator) + '</td>' +
'<td><input type="number" step="0.01" class="form-input" style="width:90px" value="' + (item.audit_price || 0) + '" onchange="updateAuditPrice(\'' + code + '\', this.value)"></td>' +
'<td>' + escHtml(item.created_by || '-') + '</td>' +
'<td>' + ((item.created_at || '').toString().slice(0, 19) || '-') + '</td>' +
'<td>' + renderNameBadges(item.procurement_follower) + '</td>' +
'<td>' + renderNameBadges(item.procurement_planner) + '</td>' +
'<td>' + ((item.created_at || '').toString().slice(0, 19) || '-') + '</td>' +
'<td class="col-action" style="text-align:center;white-space:nowrap;position:sticky;right:0;background:var(--bg,#fff);z-index:2;border-left:1px solid var(--border)">' +
'<button class="btn btn-sm btn-outline" onclick="pendingDetail(\'' + code + '\')" title="详情">详情</button> ' +
'<button class="btn btn-sm btn-outline-danger" onclick="removePending(\'' + code + '\')" title="回退">回退</button>' +
'</td>' +
'</tr>';
}).join('');
pendingUpdateCount();
});
}
function pendingToggleAll(checked) {
document.querySelectorAll('.pending-row-cb').forEach(function(cb) { cb.checked = checked; });
pendingUpdateCount();
}
function pendingUpdateCount() {
var total = document.querySelectorAll('.pending-row-cb').length;
var checked = document.querySelectorAll('.pending-row-cb:checked').length;
var el = document.getElementById('pendingSelectedCount');
if (el) el.textContent = checked > 0 ? '已选 ' + checked + '/' + total : '';
document.getElementById('pendingSelectAllCb').checked = total > 0 && checked === total;
}
function showPendingConfirmModal(item, onConfirm) {
// 先显示 loading 弹窗,同时加载BOM数据
var modal = document.createElement('div');
modal.id = 'pendingConfirmModal';
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;overflow-y:auto';
modal.innerHTML = '<div style="background:#fff;border-radius:12px;width:600px;max-width:90vw;max-height:80vh;overflow-y:auto;box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:20px">' +
'<div style="padding:24px;text-align:center;color:#999">加载中...</div></div>';
document.body.appendChild(modal);
// 异步加载BOM详情(获取包材供应商和单价)
fetch('/api/product/detail_with_bom?code=' + encodeURIComponent(item.product_code))
.then(function(r) { return r.json(); })
.then(function(res) {
var bomMaterials = (res.code === 0 && res.data && res.data.bom_materials) ? res.data.bom_materials : [];
renderPendingConfirmModal(modal, item, bomMaterials, onConfirm);
})
.catch(function() {
renderPendingConfirmModal(modal, item, [], onConfirm);
});
}
function renderPendingConfirmModal(modal, item, bomMaterials, onConfirm) {
var productCode = item.product_code || '';
var supplierName = item.supplier_name || '-';
var createdBy = item.created_by || '-';
var suggestedQty = item.suggested_qty || 0;
var actualQty = item.formal_order_qty || 0;
var auditPrice = item.audit_price || 0;
var hasBom = bomMaterials.length > 0;
// 成品信息字段:2列布局
var prodPairs = [
[['货品编号', productCode], ['货品名称', item.product_name || '-']],
[['供应商名称', supplierName], ['下单人员', createdBy]],
[['建议下单数量', suggestedQty], ['实际下单数量', actualQty]]
];
// 包材信息字段:2列布局
// 每行展示一个物料的所有4个字段:包材编号 | 包材名称 | 供应商名称 | 单价
var formalQtyConfirm = item.formal_order_qty || 0;
// 获取已保存的包材实际下单数量
var savedActualMap = {};
if (item.bom_items) {
try {
var savedBom = typeof item.bom_items === 'string' ? JSON.parse(item.bom_items) : item.bom_items;
savedBom.forEach(function(s) {
if (s.material_code) savedActualMap[s.material_code] = s.actual_quantity || 0;
});
} catch(e) {}
}
var bomRows = [];
if (hasBom) {
bomMaterials.forEach(function(b) {
var matSupplier = b.material_supplier || '-';
var matPrice = b.material_cost !== undefined ? '¥' + parseFloat(b.material_cost || 0).toFixed(4) : '-';
var bomQty = b.bom_qty || 0;
var suggestedQty = Math.ceil(formalQtyConfirm * bomQty);
var actualQty = savedActualMap[b.material_code] || 0;
bomRows.push({
code: b.material_code || '',
name: b.material_name || '',
suggested_qty: suggestedQty,
actual_qty: actualQty,
supplier: matSupplier,
price: matPrice
});
});
}
var cellBorder = 'border:1px solid #e8e8e8';
var thBaseLight = 'padding:7px 10px;' + cellBorder + ';text-align:center;background:#e8e8e8;color:#555;font-weight:500;white-space:nowrap';
var thBaseDark = 'padding:7px 10px;' + cellBorder + ';text-align:center;background:#555;color:#fff;font-weight:500;white-space:nowrap';
var tdBase = 'padding:7px 10px;' + cellBorder + ';text-align:center';
var html = '<div style="padding:20px 24px 8px">';
html += '<h3 style="margin-bottom:16px;font-size:17px">请核对商品信息</h3>';
// ─── 成品信息 ───
html += '<div class="pd-card" style="margin-bottom:12px">';
html += '<div class="pd-card-header"><span>🏭 成品信息</span></div>';
html += '<div class="pd-card-body" style="padding:6px 14px">';
html += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
prodPairs.forEach(function(pair) {
var left = pair[0], right = pair[1];
html += '<tr>';
html += '<th style="' + thBaseLight + ';width:100px">' + left[0] + '</th>';
html += '<td style="' + tdBase + ';font-weight:600">' + escHtml(String(left[1] || '-')) + '</td>';
html += '<th style="' + thBaseLight + ';width:100px">' + right[0] + '</th>';
html += '<td style="' + tdBase + ';font-weight:600">' + escHtml(String(right[1] || '-')) + '</td>';
html += '</tr>';
});
html += '</table></div></div>';
// ─── 包材信息 ───
if (hasBom) {
html += '<div class="pd-card">';
html += '<div class="pd-card-header"><span>📦 包材信息</span></div>';
html += '<div class="pd-card-body" style="padding:0;overflow-x:auto">';
html += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
html += '<thead><tr style="background:#555;color:#fff">';
html += '<th style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">包材编号</th>';
html += '<th style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">包材名称</th>';
html += '<th style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">建议下单数量</th>';
html += '<th style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">实际下单数量</th>';
html += '<th style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">供应商名称</th>';
html += '<th style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">单价</th>';
html += '</tr></thead><tbody>';
bomRows.forEach(function(b, i) {
var bg = i % 2 === 0 ? '#fff' : '#f5f5f5';
html += '<tr style="background:' + bg + '">';
html += '<td style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;font-weight:600;white-space:nowrap"><code style="font-weight:700;color:#333">' + escHtml(b.code) + '</code></td>';
html += '<td style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;font-weight:600;color:#333;white-space:nowrap">' + escHtml(b.name) + '</td>';
html += '<td style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;font-weight:600;color:#1890ff;white-space:nowrap">' + (b.suggested_qty || 0).toLocaleString() + '</td>';
html += '<td style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;font-weight:600;color:#d4380d;white-space:nowrap">' + (b.actual_qty || 0).toLocaleString() + '</td>';
html += '<td style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">' + escHtml(b.supplier) + '</td>';
html += '<td style="padding:8px 12px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">' + escHtml(b.price) + '</td>';
html += '</tr>';
});
html += '</tbody></table></div></div>';
} else {
html += '<div class="pd-card">';
html += '<div class="pd-card-header"><span>📦 包材信息</span></div>';
html += '<div class="pd-card-body" style="padding:16px 14px;text-align:center;color:#999;font-size:13px">暂无包材数据</div></div>';
}
html += '</div>';
html += '<div style="padding:16px 24px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:10px">';
html += '<button class="btn" id="pendingConfirmCancelBtn">取消</button>';
html += '<button class="btn btn-primary" id="pendingConfirmOkBtn">✅ 确认无误,下单</button></div>';
modal.innerHTML = '<div style="background:#fff;border-radius:12px;width:860px;max-width:95vw;max-height:85vh;overflow-y:auto;box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:20px">' + html + '</div>';
document.getElementById('pendingConfirmCancelBtn').onclick = function() { modal.remove(); };
document.getElementById('pendingConfirmOkBtn').onclick = function() {
modal.remove();
if (onConfirm) onConfirm();
};
}
function pendingBatchConfirm() {
var checkedCodes = [];
document.querySelectorAll('.pending-row-cb:checked').forEach(function(cb) {
checkedCodes.push(cb.value);
});
if (checkedCodes.length === 0) {
showCenteredAlert('提示', '请先勾选要确认下单的商品');
return;
}
showCenteredConfirm('批量下单确认', '确认对选中的 <strong>' + checkedCodes.length + '</strong> 个商品进行下单操作?').then(function(confirmed) {
if (!confirmed) return;
fetch('/api/purchase/confirm_order', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_codes: checkedCodes})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
showCenteredAlert('操作成功', '✅ 批量下单成功,共 ' + checkedCodes.length + ' 个');
loadPendingList();
} else {
showCenteredAlert('操作失败', '❌ ' + (res.msg || '操作失败'));
}
})
.catch(function(err) {
showCenteredAlert('网络错误', '❌ 网络错误: ' + (err.message || '请求失败'));
});
});
}
function pendingConfirmOrder(productCode) {
// 查找该商品在列表中的数据
var item = null;
(_pendingItems || []).forEach(function(it) {
if (it.product_code === productCode) item = it;
});
if (!item) { showCenteredAlert('提示', '未找到该商品数据'); return; }
showCenteredConfirm('下单确认', '确认对 <strong>[' + escHtml(productCode) + ']</strong> 进行下单操作?').then(function(confirmed) {
if (!confirmed) return;
fetch('/api/purchase/confirm_order', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_codes: [productCode]})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
showCenteredAlert('操作成功', '✅ 下单成功');
loadPendingList();
} else {
showCenteredAlert('操作失败', '❌ ' + (res.msg || '操作失败'));
}
})
.catch(function(err) {
showCenteredAlert('网络错误', '❌ 网络错误: ' + (err.message || '请求失败'));
});
});
}
// ─── 下单备注保存 ───
window.remarkEnterSave = function(e) {
if (e.key === 'Enter') {
e.preventDefault();
e.target.blur();
}
};
window.savePendingRemark = function(input) {
var remark = input.value.trim();
var code = input.getAttribute('data-pcode');
if (!code) return;
// 同步更新本地缓存
(_pendingItems || []).forEach(function(it) {
if (it.product_code === code) it.remark = remark;
});
fetch('/api/purchase/update_pending', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: code, remark: remark})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code !== 0) console.warn('保存备注失败:', res.msg);
})
.catch(function(e){ console.warn('保存备注出错:', e); });
};
// 待下单冻结列功能
window._pendingFrozen = false;
// 待下单管理 - 列字段设置
window._pendingColVisible = {};
window._pendingColOrder = [
'checkbox','index','product_type','product_code','product_name',
'confirm_order','product_grade','supplier_name','remark',
'lead_time','logistics_time','total_support_days',
'procurement_follower','procurement_planner','platform','operator',
'audit_price','created_by','created_time','suggested_qty','formal_qty','created_at','action'
];
var PENDING_COL_LABELS = {
checkbox:'✔','index':'序号','product_type':'产品类型','product_code':'货品编号','product_name':'货品名称',
confirm_order:'确认下单','product_grade':'产品分级','supplier_name':'供应商名称','remark':'下单备注',
lead_time:'货期','logistics_time':'物流','total_support_days':'总支撑天数',
procurement_follower:'采购跟单','procurement_planner':'采购计划','platform':'归属平台','operator':'运营专员',
audit_price:'审核价格',created_by:'下单人员',created_time:'下单时间',
'suggested_qty':'建议下单数量','formal_qty':'正式下单数量',
created_at:'加入时间','action':'操作'
};
window.openPendingColModal = function() {
// 确保字段顺序完整无重复
var defaultOrder = ['checkbox','index','product_type','product_code','product_name',
'confirm_order','product_grade','supplier_name','remark',
'lead_time','logistics_time','total_support_days',
'procurement_follower','procurement_planner','platform','operator',
'audit_price','created_by','created_time','suggested_qty','formal_qty','created_at','action'];
if (!window._pendingColOrder || window._pendingColOrder.length < 10) {
window._pendingColOrder = defaultOrder.slice();
}
// 去重
var seen = {};
window._pendingColOrder = window._pendingColOrder.filter(function(f) {
if (seen[f]) return false;
seen[f] = true;
return true;
});
// 补齐缺失字段
defaultOrder.forEach(function(f) {
if (window._pendingColOrder.indexOf(f) === -1) {
window._pendingColOrder.push(f);
}
});
var order = window._pendingColOrder.slice();
var modalId = 'pendingColModal-' + Date.now();
var modal = document.createElement('div');
modal.id = modalId;
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);z-index:10000;display:flex;align-items:center;justify-content:center;padding:20px';
var bodyHtml = '<div style="background:#fff;border-radius:12px;width:480px;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 8px 24px rgba(0,0,0,0.15);overflow:hidden">' +
'<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid #eee">' +
'<h3 style="margin:0;font-size:16px">⚙ 字段设置</h3>' +
'<button style="font-size:22px;border:none;background:none;cursor:pointer;color:#999" onclick="document.getElementById(\'' + modalId + '\').remove()">✕</button>' +
'</div>' +
'<div style="padding:8px 0;overflow-y:auto;flex:1">' +
'<div style="padding:6px 20px;border-bottom:1px solid #f5f5f5;">' +
'<label style="font-size:13px;cursor:pointer"><input type="checkbox" id="' + modalId + '-all" onchange="var c=this.checked;document.querySelectorAll(\'#' + modalId + '-body .pending-col-cb\').forEach(function(b){b.checked=c})"> 全选</label>' +
'</div>' +
'<div id="' + modalId + '-body" style="column-count:3;column-gap:8px;padding:8px 16px">';
order.forEach(function(field, idx) {
if (field === 'checkbox' || field === 'action') return;
var label = PENDING_COL_LABELS[field] || field;
var checked = window._pendingColVisible[field] !== false ? ' checked' : '';
bodyHtml += '<div class="pending-col-row" draggable="true" data-field="' + field + '" style="display:flex;align-items:center;gap:6px;padding:6px 8px;font-size:13px;cursor:grab;border-radius:4px;break-inside:avoid" ' +
'ondragstart="event.dataTransfer.setData(\'text/plain\',this.dataset.field)" ' +
'ondrop="event.preventDefault();var f=event.dataTransfer.getData(\'text/plain\');var t=this.dataset.field;var p=this.parentNode;var items=Array.from(p.querySelectorAll(\'.pending-col-row\'));var fi=items.findIndex(function(x){return x.dataset.field===f});var ti=items.findIndex(function(x){return x.dataset.field===t});if(fi>=0&&ti>=0){var el=items[fi];if(fi<ti){p.insertBefore(el,items[ti].nextSibling)}else{p.insertBefore(el,items[ti])}}" ' +
'ondragover="event.preventDefault()">' +
'<span style="color:#bbb;font-size:12px;cursor:grab">⠿</span>' +
'<input type="checkbox" class="pending-col-cb" data-field="' + field + '"' + checked + ' style="width:15px;height:15px"> ' + label +
'</div>';
});
bodyHtml += '</div></div>' +
'<div style="padding:10px 20px;border-top:1px solid #eee;text-align:right;display:flex;gap:8px;justify-content:flex-end">' +
'<button class="btn btn-sm" onclick="document.getElementById(\'' + modalId + '\').remove()">取消</button>' +
'<button class="btn btn-sm btn-primary" onclick="savePendingColSettings(\'' + modalId + '\')">💾 保存</button>' +
'</div></div>';
modal.innerHTML = bodyHtml;
document.body.appendChild(modal);
};
window.savePendingColSettings = function(modalId) {
// 读取顺序
var rows = document.querySelectorAll('#' + modalId + '-body .pending-col-row');
var newOrder = ['checkbox', 'index'];
rows.forEach(function(row) {
newOrder.push(row.dataset.field);
});
newOrder.push('action');
window._pendingColOrder = newOrder;
// 读取可见性
document.querySelectorAll('#' + modalId + '-body .pending-col-cb').forEach(function(cb) {
window._pendingColVisible[cb.dataset.field] = cb.checked;
});
document.getElementById(modalId).remove();
applyPendingColSettings();
};
function applyPendingColSettings() {
var order = window._pendingColOrder || [];
var tbl = document.getElementById('pendingTable');
if (!tbl) return;
var thead = tbl.querySelector('thead tr');
var tbody = document.getElementById('pendingTbody');
if (!thead) return;
// 获取字段到TH/TD索引的映射(按当前DOM顺序)
var fieldToThIdx = {};
var ths = thead.querySelectorAll('th');
var fieldMap = ['checkbox','index','product_type','product_code','product_name',
'confirm_order','product_grade','supplier_name','remark',
'lead_time','logistics_time','total_support_days',
'procurement_follower','procurement_planner','platform','operator',
'audit_price','created_by','created_time','suggested_qty','formal_qty','created_at','action'];
ths.forEach(function(th, i) {
if (i < fieldMap.length) fieldToThIdx[fieldMap[i]] = i;
});
// 重新排列表头
order.forEach(function(field, newIdx) {
var oldIdx = fieldToThIdx[field];
if (oldIdx === undefined || oldIdx >= ths.length) return;
var target = thead.children[newIdx];
var el = ths[oldIdx];
if (el !== target) {
thead.insertBefore(el, target || null);
}
});
// 重新排列数据行
if (tbody) {
var fieldToTdIdx = {};
fieldMap.forEach(function(f, i) { fieldToTdIdx[f] = i; });
tbody.querySelectorAll('tr').forEach(function(tr) {
var tds = tr.querySelectorAll('td');
order.forEach(function(field, newIdx) {
var oldIdx = fieldToTdIdx[field];
if (oldIdx === undefined || oldIdx >= tds.length) return;
var target = tr.children[newIdx];
var el = tds[oldIdx];
if (el !== target) {
tr.insertBefore(el, target || null);
}
});
});
}
// 设置可见性
order.forEach(function(field) {
var vis = window._pendingColVisible[field];
var hide = vis === false || (field !== 'checkbox' && field !== 'index' && field !== 'action' && vis === false);
// 简化:隐藏字段通过样式
});
// 刷新冻结
if (window._pendingFrozenActive) {
applyPendingFrozen(window._pendingFrozenFields);
}
}
// ─── 下单备份管理 - 字段设置 ───
if (!window._backupColVisible) { window._backupColVisible = {}; window._backupColOrder = null; }
var BACKUP_COL_ORDER = ['index','product_type','product_code','product_name',
'ordered_at','product_grade','supplier_name','remark',
'lead_time','logistics_time','total_support_days',
'procurement_follower','procurement_planner','platform','operator','created_by',
'audit_price','suggested_qty','formal_qty','created_at','action'];
var BACKUP_COL_LABELS = {
index:'序号',product_type:'产品类型',product_code:'货品编号',product_name:'货品名称',
ordered_at:'下单时间',product_grade:'产品分级',supplier_name:'供应商名称',remark:'下单备注',
lead_time:'货期',logistics_time:'物流',total_support_days:'总支撑天数',
procurement_follower:'采购跟单',procurement_planner:'采购计划',platform:'归属平台',operator:'运营专员',created_by:'下单人员',
audit_price:'审核价格',suggested_qty:'建议下单数量',formal_qty:'正式下单数量',
created_at:'加入时间',action:'操作'
};
window.openBackupColModal = function() {
if (!window._backupColOrder) { window._backupColOrder = BACKUP_COL_ORDER.slice(); }
var order = window._backupColOrder.slice();
var modalId = 'backupColModal-' + Date.now();
var modal = document.createElement('div');
modal.id = modalId;
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);z-index:10000;display:flex;align-items:center;justify-content:center;padding:20px';
var bodyHtml = '<div style="background:#fff;border-radius:12px;width:480px;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 8px 24px rgba(0,0,0,0.15);overflow:hidden">' +
'<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid #eee">' +
'<h3 style="margin:0;font-size:16px">⚙ 字段设置(下单备份)</h3>' +
'<button style="font-size:22px;border:none;background:none;cursor:pointer;color:#999" onclick="document.getElementById(\'' + modalId + '\').remove()">✕</button>' +
'</div>' +
'<div style="padding:8px 0;overflow-y:auto;flex:1">' +
'<div style="padding:6px 20px;border-bottom:1px solid #f5f5f5">' +
'<label style="font-size:13px;cursor:pointer"><input type="checkbox" id="' + modalId + '-all" onchange="var c=this.checked;document.querySelectorAll(\'#' + modalId + '-body .backup-col-cb\').forEach(function(b){b.checked=c})"> 全选</label>' +
'</div>' +
'<div id="' + modalId + '-body" style="column-count:3;column-gap:8px;padding:8px 16px">';
order.forEach(function(field) {
if (field === 'action') return;
var label = BACKUP_COL_LABELS[field] || field;
var checked = window._backupColVisible[field] !== false ? ' checked' : '';
bodyHtml += '<label style="display:flex;align-items:center;gap:6px;padding:5px 8px;font-size:13px;break-inside:avoid;cursor:pointer">' +
'<input type="checkbox" class="backup-col-cb" data-field="' + field + '"' + checked + ' style="width:15px;height:15px"> ' + label +
'</label>';
});
bodyHtml += '</div></div>' +
'<div style="padding:10px 20px;border-top:1px solid #eee;text-align:right;display:flex;gap:8px;justify-content:flex-end">' +
'<button class="btn btn-sm" onclick="document.getElementById(\'' + modalId + '\').remove()">取消</button>' +
'<button class="btn btn-sm btn-primary" onclick="saveBackupColSettings(\'' + modalId + '\')">💾 保存</button>' +
'</div></div>';
modal.innerHTML = bodyHtml;
document.body.appendChild(modal);
};
window.saveBackupColSettings = function(modalId) {
document.querySelectorAll('#' + modalId + '-body .backup-col-cb').forEach(function(cb) {
window._backupColVisible[cb.dataset.field] = cb.checked;
});
document.getElementById(modalId).remove();
alert('字段设置已保存,刷新后生效');
};
// 待下单管理 - 冻结列标签(显示在每列底部)
var _pendingFrozenFields = [];
var _pendingFrozenActive = false;
window.togglePendingFrozen = function() {
var labels = ['序号','产品类型','货品编号','货品名称'];
var modalId = 'pendingFrozenModal-' + Date.now();
var modal = document.createElement('div');
modal.id = modalId;
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);z-index:10000;display:flex;align-items:center;justify-content:center;padding:20px';
var html = '<div style="background:#fff;border-radius:12px;width:400px;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 8px 24px rgba(0,0,0,0.15);overflow:hidden">' +
'<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid #eee">' +
'<h3 style="margin:0;font-size:16px">🔒 选择冻结列</h3>' +
'<button style="font-size:22px;border:none;background:none;cursor:pointer;color:#999" onclick="document.getElementById(\'' + modalId + '\').remove()">✕</button>' +
'</div>' +
'<div style="padding:12px 20px;overflow:auto;flex:1" id="' + modalId + '-body">';
labels.forEach(function(label, i) {
var checked = _pendingFrozenFields.indexOf(i + 1) !== -1 ? ' checked' : '';
html += '<label style="display:flex;align-items:center;padding:6px 0;cursor:pointer;font-size:14px">' +
'<input type="checkbox" data-col="' + (i + 1) + '"' + checked + ' style="margin-right:10px;width:16px;height:16px"> ' + label +
'</label>';
});
html += '</div>' +
'<div style="padding:10px 20px;border-top:1px solid #eee;text-align:right;display:flex;gap:8px;justify-content:flex-end">' +
'<button class="btn btn-sm" onclick="_pendingFrozenFields=[];document.getElementById(\'' + modalId + '\').remove();applyPendingFrozen([])">取消冻结</button>' +
'<button class="btn btn-sm btn-primary" onclick="var cbs=document.querySelectorAll(\'#' + modalId + '-body input[type=checkbox]:checked\');var arr=[];cbs.forEach(function(cb){arr.push(parseInt(cb.getAttribute(\'data-col\')))});_pendingFrozenFields=arr;document.getElementById(\'' + modalId + '\').remove();applyPendingFrozen(arr)">✅ 确认</button>' +
'</div></div>';
modal.innerHTML = html;
modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
document.body.appendChild(modal);
};
function applyPendingFrozen(frozenIdx) {
_pendingFrozenActive = frozenIdx.length > 0;
var btn = document.getElementById('pendingFrozenBtn');
if (btn) {
btn.style.background = _pendingFrozenActive ? '#bae7ff' : '';
btn.style.border = _pendingFrozenActive ? '1px solid #1890ff' : '';
btn.textContent = _pendingFrozenActive ? '🔓 取消冻结' : '🔒 冻结列';
}
var tbl = document.getElementById('pendingTable');
if (!tbl) return;
// 清除所有冻结
tbl.querySelectorAll('[style*="sticky"]').forEach(function(el) {
el.style.position = ''; el.style.left = ''; el.style.zIndex = ''; el.style.background = '';
});
tbl.querySelectorAll('th, td').forEach(function(el) {
el.style.position = ''; el.style.left = ''; el.style.zIndex = ''; el.style.background = '';
});
var ths = tbl.querySelectorAll('thead tr th');
var rows = tbl.querySelectorAll('tbody tr');
// 按从左到右顺序冻结选中列
frozenIdx.sort(function(a,b){return a-b;});
// 先冻结th
frozenIdx.forEach(function(idx) {
if (!ths[idx]) return;
var left = 0;
for (var i = 0; i < idx; i++) {
if (frozenIdx.indexOf(i) !== -1) {
left += (ths[i] ? (ths[i].offsetWidth || ths[i].getBoundingClientRect().width || 50) : 50);
}
}
ths[idx].style.cssText = 'position:sticky;left:' + left + 'px;z-index:11;background:var(--bg,#f5f5f5)';
});
// 同步冻结数据行
frozenIdx.forEach(function(idx) {
rows.forEach(function(tr) {
var tds = tr.querySelectorAll('td');
if (!tds[idx]) return;
var left = 0;
for (var i = 0; i < idx; i++) {
if (frozenIdx.indexOf(i) !== -1) {
left += (tds[i] ? (tds[i].offsetWidth || tds[i].getBoundingClientRect().width || 50) : 50);
}
}
tds[idx].style.cssText = 'position:sticky;left:' + left + 'px;z-index:2;background:var(--card-bg,#fff)';
});
});
}
// 采购跟单/计划按名称生成颜色标签
function colorForName(name) {
if (!name || name === '-') return null;
var colors = ['#4d9aff','#e8579e','#7ecf4f','#fba84a','#9d5ee0','#4dd4d4','#f062ae','#fbc44d','#5f7af0','#b8e54a','#f75a62','#52abff','#63be3f','#e56540','#7d4ac4'];
var hash = 0;
for (var i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); }
return colors[Math.abs(hash) % colors.length];
}
function textColorForBG(bg) {
if (!bg) return '#333';
var c = bg.replace('#','');
var r = parseInt(c.substr(0,2),16), g = parseInt(c.substr(2,2),16), b = parseInt(c.substr(4,2),16);
return (r*0.299 + g*0.587 + b*0.114) > 140 ? '#333' : '#fff';
}
function renderNameBadges(names) {
if (!names || names === '-' || names === '') return '<span style="color:#bbb">-</span>';
var nameList = names.split(',').map(function(n) { return n.trim(); }).filter(function(n) { return n; });
if (nameList.length === 0) return '<span style="color:#bbb">-</span>';
var html = '';
nameList.forEach(function(name) {
var bg = colorForName(name) || '#e6f7ff';
var fc = textColorForBG(bg);
html += '<span style="display:inline-block;padding:2px 10px;border-radius:12px;font-size:13px;font-weight:600;margin:1px 3px;background:' + bg + ';color:' + fc + '">' + escHtml(name) + '</span>';
});
return html;
}
function toggleBom(btn, productCode) {
var tr = btn.closest('tr');
var codeSafe = productCode.replace(/[^a-zA-Z0-9]/g, '_');
var bomRowId = 'bom-row-' + codeSafe;
var existing = document.getElementById(bomRowId);
if (existing) {
existing.remove();
btn.textContent = '📋 BOM';
return;
}
// 查找BOM数据
var item = null;
(_pendingItems || []).forEach(function(it) {
if (it.product_code === productCode) item = it;
});
var bom = item ? item.bom_items : null;
var bomList = [];
if (bom) {
try {
bomList = typeof bom === 'string' ? JSON.parse(bom) : bom;
} catch(e) {}
}
if (!bomList || bomList.length === 0) {
alert('该商品暂无物料清单');
return;
}
var formalQty = item ? (item.formal_order_qty || 0) : 0;
// BOM内容
var bomContent = '<div style="overflow-x:auto;overscroll-behavior:contain"><table style="font-size:13px;border-collapse:collapse;width:100%">';
bomContent += '<thead><tr style="background:#e6f7ff"><th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">物料编号</th><th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">物料名称</th><th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">用量</th><th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">建议下单数量</th><th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">实际下单数量</th><th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">下单前包材</th><th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">下单后包材</th><th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">下单备注</th><th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">更换稿件</th><th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">操作</th></tr></thead><tbody>';
bomList.forEach(function(b) {
var matCode = escHtml(b.material_code || '');
var qty = b.quantity || 0;
var orderQty = Math.ceil(formalQty * qty);
// 使用已保存的实际下单数量(如有)
var actualQty = (b.actual_quantity > 0) ? b.actual_quantity : orderQty;
bomContent += '<tr><td style="padding:6px 10px;border:1px solid #eee;word-break:break-all"><code style="font-size:14px;font-weight:600">' + matCode + '</code></td>';
bomContent += '<td style="padding:6px 10px;border:1px solid #eee;word-break:break-word;font-size:14px">' + escHtml(b.material_name || '') + '</td>';
bomContent += '<td style="padding:6px 10px;border:1px solid #eee">' + qty + '</td>';
bomContent += '<td style="padding:6px 10px;border:1px solid #eee"><input type="number" class="form-input" style="width:55px" value="' + orderQty + '" data-material="' + matCode + '" data-product="' + escHtml(productCode) + '"></td>';
bomContent += '<td style="padding:6px 10px;border:1px solid #eee"><input type="number" class="form-input bom-actual-qty" style="width:55px;font-weight:600;color:#d4380d" value="' + actualQty + '" data-material="' + matCode + '" data-product="' + escHtml(productCode) + '" placeholder="实下单"></td>';
bomContent += '<td style="padding:6px 10px;border:1px solid #eee"><input type="number" class="form-input" style="width:50px" value="0"></td>';
bomContent += '<td style="padding:6px 10px;border:1px solid #eee"><input type="number" class="form-input" style="width:50px" value="0"></td>';
bomContent += '<td style="padding:6px 10px;border:1px solid #eee"><input type="text" class="form-input" style="width:100%;min-width:180px;box-sizing:border-box" value=""></td>';
bomContent += '<td style="padding:6px 10px;border:1px solid #eee"><select class="form-input bom-manuscript" style="width:60px" data-material="' + matCode + '"><option value="">否</option><option value="是">是</option></select></td>';
bomContent += '<td style="padding:6px 10px;border:1px solid #eee"><button class="btn btn-sm" style="background:#e6fffb;color:#13c2c2;border:1px solid #87e8de" onclick="generateMaterialOrder(\'' + escHtml(productCode) + '\', \'' + matCode + '\')">生成</button></td></tr>';
});
bomContent += '</tbody></table></div>';
// 弹窗展示
var modalId = 'bom-modal-' + Date.now();
var modal = document.createElement('div');
modal.id = modalId;
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);z-index:10000;display:flex;align-items:center;justify-content:center;overflow-y:auto;padding:20px';
modal.innerHTML = '<div style="background:#fff;border-radius:12px;width:95vw;max-width:1600px;max-height:88vh;display:flex;flex-direction:column;box-shadow:0 8px 24px rgba(0,0,0,0.15);overflow:hidden">' +
'<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 24px;border-bottom:1px solid #eee;flex:0 0 auto">' +
'<h3 style="margin:0;font-size:16px">📋 物料清单 [<strong>' + escHtml(productCode) + '</strong>]</h3>' +
'<button style="font-size:22px;border:none;background:none;cursor:pointer;padding:0 6px;line-height:1;color:#999" onclick="document.getElementById(\'' + modalId + '\').remove()">✕</button>' +
'</div>' +
'<div style="padding:16px 24px;overflow:auto;flex:1 1 auto">' + bomContent + '</div>' +
'<div style="padding:10px 24px;border-top:1px solid #eee;text-align:right;flex:0 0 auto;display:flex;gap:8px;justify-content:flex-end">' +
'<button class="btn btn-primary" onclick="saveBomData(\'' + escHtml(productCode) + '\')">💾 保存</button>' +
'<button class="btn" onclick="document.getElementById(\'' + modalId + '\').remove()">关闭</button>' +
'</div></div>';
modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
document.body.appendChild(modal);
}
window.saveBomData = function(productCode) {
var inputs = document.querySelectorAll('.bom-actual-qty[data-product="' + productCode + '"]');
var materials = [];
inputs.forEach(function(inp) {
var qty = parseInt(inp.value) || 0;
if (qty > 0) {
var matCode = inp.getAttribute('data-material');
// 读取同行建议下单数量
var orderInput = document.querySelector('.bom-order-qty[data-product="' + productCode + '"][data-material="' + matCode + '"]');
var bomQty = orderInput ? (parseInt(orderInput.value) || 0) : 0;
// 读取物料名称(从BOM弹窗内的表格行中获取)
var matName = '';
if (orderInput) {
var row = orderInput.closest('tr');
if (row) {
var nameCell = row.querySelector('td:nth-child(2)');
if (nameCell) matName = nameCell.textContent.trim();
}
}
// 读取同行备注
var remarkInput = document.querySelector('.bom-remark[data-material="' + matCode + '"]');
var remarkVal = remarkInput ? remarkInput.value.trim() : '';
// 读取是否修改稿件
var manuscriptSelect = document.querySelector('.bom-manuscript[data-material="' + matCode + '"]');
var manuscriptVal = manuscriptSelect ? manuscriptSelect.value : '';
materials.push({
material_code: matCode,
material_name: matName,
quantity: bomQty,
actual_quantity: qty,
remark: remarkVal,
change_manuscript: manuscriptVal
});
}
});
if (materials.length === 0) {
alert('没有实际下单数量>0的物料');
return;
}
// 存到前端变量,下单时一起提交
if (!window._pendingBomData) window._pendingBomData = {};
window._pendingBomData[productCode] = materials;
// 关闭BOM弹窗
var bomInput = document.querySelector('.bom-actual-qty[data-product="' + productCode + '"]');
if (bomInput) {
var modal = bomInput.closest('[style*="z-index: 10000"]') || bomInput.closest('[id^="bom-modal-"]');
if (modal) modal.remove();
}
showCenteredAlert('保存成功', '✅ BOM已保存,确认下单时将自动提交包材数据');
};
// ─── 计划预测 BOM 物料清单(完整编辑功能) ───
function planToggleBom(productCode) {
fetch('/api/product/detail_with_bom?code=' + encodeURIComponent(productCode))
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0 || !res.data) {
alert('❌ 获取物料清单失败');
return;
}
var bom = res.data.bom_materials || [];
if (bom.length === 0) {
alert('该商品暂无物料清单');
return;
}
// 获取正式下单数量(从计划预测页面元素获取)
var formalQtySpan = document.getElementById('fqv-' + productCode);
var formalQty = formalQtySpan ? (parseInt(formalQtySpan.dataset.val) || 0) : 0;
var modalId = 'bom-modal-' + Date.now();
var bomContent = '<div style="overflow-x:auto;overscroll-behavior:contain"><table style="font-size:13px;border-collapse:collapse;width:100%">' +
'<thead><tr style="background:#e6f7ff">' +
'<th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">物料编号</th>' +
'<th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">物料名称</th>' +
'<th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">用量</th>' +
'<th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">建议下单数量</th>' +
'<th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">实际下单数量</th>' +
'<th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">下单前包材</th>' +
'<th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">下单后包材</th>' +
'<th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">下单备注</th>' +
'<th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">更换稿件</th>' +
'<th style="padding:6px 10px;border:1px solid #d9d9d9;text-align:left;white-space:nowrap">操作</th>' +
'</tr></thead><tbody>';
// 读取之前保存的包材数据
var savedBom = window._pendingBomData && window._pendingBomData[productCode] ? window._pendingBomData[productCode] : [];
var savedMap = {};
var savedRemarkMap = {};
var savedManuscriptMap = {};
savedBom.forEach(function(s) {
savedMap[s.material_code] = s.actual_quantity;
savedRemarkMap[s.material_code] = s.remark || '';
savedManuscriptMap[s.material_code] = s.change_manuscript || '';
});
bom.forEach(function(b) {
var matCode = escHtml(b.material_code || '');
var qty = b.bom_qty || 0;
var orderQty = Math.ceil(formalQty * qty);
// 如果有保存的数据,用保存的实际下单数量
var savedActual = savedMap[b.material_code] || 0;
var actualQty = savedActual > 0 ? savedActual : orderQty;
bomContent += '<tr>' +
'<td style="padding:6px 10px;border:1px solid #eee;word-break:break-all"><code style="font-size:13px;font-weight:600">' + matCode + '</code></td>' +
'<td style="padding:6px 10px;border:1px solid #eee;word-break:break-word;font-size:13px">' + escHtml(b.material_name || '') + '</td>' +
'<td style="padding:6px 10px;border:1px solid #eee">' + qty + '</td>' +
'<td style="padding:6px 10px;border:1px solid #eee"><input type="number" class="form-input bom-order-qty" style="width:70px;background:#f5f5f5;color:#999" value="' + orderQty + '" data-material="' + matCode + '" data-product="' + escHtml(productCode) + '" readonly></td>' +
'<td style="padding:6px 10px;border:1px solid #eee"><input type="number" class="form-input bom-actual-qty" style="width:70px;font-weight:600;color:#d4380d" value="' + actualQty + '" data-material="' + matCode + '" data-product="' + escHtml(productCode) + '" placeholder="实下单"></td>' +
'<td style="padding:6px 10px;border:1px solid #eee"><input type="number" class="form-input" style="width:60px" value="0"></td>' +
'<td style="padding:6px 10px;border:1px solid #eee"><input type="number" class="form-input" style="width:50px" value="0"></td>' +
'<td style="padding:6px 10px;border:1px solid #eee"><input type="number" class="form-input" style="width:50px" value="0"></td>' +
'<td style="padding:6px 10px;border:1px solid #eee"><input type="text" class="form-input bom-remark" style="width:100%;min-width:180px;box-sizing:border-box" value="' + escHtml(savedRemarkMap[b.material_code] || '') + '" data-material="' + matCode + '" placeholder="备注"></td>' +
'<td style="padding:6px 10px;border:1px solid #eee"><select class="form-input bom-manuscript" style="width:60px" data-material="' + matCode + '"><option value="">否</option><option value="是">是</option></select></td>' +
'<td style="padding:6px 10px;border:1px solid #eee"><button class="btn btn-sm" style="background:#e6fffb;color:#13c2c2;border:1px solid #87e8de" onclick="planGenerateMatOrder(\'' + escHtml(productCode) + '\', \'' + matCode + '\', \'' + escHtml((b.material_name || '').replace(/'/g, "\\'")) + '\')">生成</button></td></tr>';
});
bomContent += '</tbody></table></div>';
var modal = document.createElement('div');
modal.id = modalId;
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);z-index:10000;display:flex;align-items:center;justify-content:center;overflow-y:auto;padding:20px';
modal.innerHTML = '<div style="background:#fff;border-radius:12px;width:95vw;max-width:1600px;max-height:88vh;display:flex;flex-direction:column;box-shadow:0 8px 24px rgba(0,0,0,0.15);overflow:hidden">' +
'<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 24px;border-bottom:1px solid #eee;flex:0 0 auto">' +
'<h3 style="margin:0;font-size:16px">📋 物料清单 [<strong>' + escHtml(productCode) + '</strong>]</h3>' +
'<button style="font-size:22px;border:none;background:none;cursor:pointer;padding:0 6px;line-height:1;color:#999" onclick="document.getElementById(\'' + modalId + '\').remove()">✕</button>' +
'</div>' +
'<div style="padding:16px 24px;overflow:auto;flex:1 1 auto">' + bomContent + '</div>' +
'<div style="padding:10px 24px;border-top:1px solid #eee;text-align:right;flex:0 0 auto;display:flex;gap:8px;justify-content:flex-end">' +
'<button class="btn btn-primary" onclick="saveBomData(\'' + escHtml(productCode) + '\')">💾 保存</button>' +
'<button class="btn" onclick="document.getElementById(\'' + modalId + '\').remove()">关闭</button>' +
'</div></div>';
modal.onclick = function(e) { if (e.target === modal) {} };
document.body.appendChild(modal);
// 恢复已保存的更换稿件状态
modal.querySelectorAll('.bom-manuscript').forEach(function(sel) {
var matCode = sel.getAttribute('data-material');
if (savedManuscriptMap[matCode]) {
sel.value = savedManuscriptMap[matCode];
}
});
// 实际下单数量输入框:点击自动全选 + 回车跳到下一行
var actualInputs = modal.querySelectorAll('.bom-actual-qty');
actualInputs.forEach(function(inp, idx) {
// 点击自动全选
inp.addEventListener('focus', function() {
this.select();
});
inp.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
var matCode = inp.getAttribute('data-material');
var qty = parseInt(inp.value) || 0;
if (!window._pendingBomData) window._pendingBomData = {};
if (!window._pendingBomData[productCode]) window._pendingBomData[productCode] = [];
// 更新或添加当前物料
var found = false;
window._pendingBomData[productCode].forEach(function(m) {
if (m.material_code === matCode) {
m.actual_quantity = qty;
// 同时更新其他字段
var orderInput = document.querySelector('.bom-order-qty[data-product="' + productCode + '"][data-material="' + matCode + '"]');
if (orderInput) {
m.quantity = parseInt(orderInput.value) || 0;
var row = orderInput.closest('tr');
if (row) {
var nameCell = row.querySelector('td:nth-child(2)');
if (nameCell) m.material_name = nameCell.textContent.trim();
}
}
var remarkInput = document.querySelector('.bom-remark[data-material="' + matCode + '"]');
if (remarkInput) m.remark = remarkInput.value.trim();
found = true;
}
});
if (!found && qty > 0) {
// 读取同行建议下单数量、物料名称和备注
var orderInput = document.querySelector('.bom-order-qty[data-product="' + productCode + '"][data-material="' + matCode + '"]');
var bomQty = orderInput ? (parseInt(orderInput.value) || 0) : 0;
var matName = '';
var remarkVal = '';
if (orderInput) {
var row = orderInput.closest('tr');
if (row) {
var nameCell = row.querySelector('td:nth-child(2)');
if (nameCell) matName = nameCell.textContent.trim();
}
}
// 读取备注
var remarkInput = document.querySelector('.bom-remark[data-material="' + matCode + '"]');
if (remarkInput) remarkVal = remarkInput.value.trim();
window._pendingBomData[productCode].push({
material_code: matCode,
material_name: matName,
quantity: bomQty,
actual_quantity: qty,
remark: remarkVal
});
}
inp.style.borderColor = '#52c41a';
setTimeout(function() { inp.style.borderColor = ''; }, 800);
// 跳到下一行
var nextInput = actualInputs[idx + 1];
if (nextInput) {
nextInput.focus();
nextInput.select();
}
// 没有下一行:不做处理,保存完成
}
});
});
})
.catch(function() { alert('❌ 网络错误'); });
}
// 计划预测 BOM - 生成物料订单(不依赖 _pendingItems)
function planGenerateMatOrder(productCode, materialCode, materialName) {
var input = document.querySelector('.bom-order-qty[data-material="' + materialCode + '"][data-product="' + productCode + '"]');
var qty = input ? parseInt(input.value) || 0 : 0;
if (qty <= 0) {
alert('请先填写有效的下单数量');
return;
}
if (!confirm('确认生成物料 [' + materialCode + '] 的采购订单,数量:' + qty + '?')) return;
fetch('/api/purchase/create_material_order', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
product_code: productCode,
material_code: materialCode,
material_name: materialName,
order_qty: qty
})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
alert('✅ 物料采购单已生成: ' + res.order_no);
} else {
alert('❌ ' + (res.msg || '生成失败'));
}
});
}
function generateMaterialOrder(productCode, materialCode) {
var input = document.querySelector('.bom-order-qty[data-material="' + materialCode + '"][data-product="' + productCode + '"]');
var qty = input ? parseInt(input.value) || 0 : 0;
if (qty <= 0) {
alert('请先填写有效的下单数量');
return;
}
if (!confirm('确认生成物料 [' + materialCode + '] 的采购订单,数量:' + qty + '?')) return;
// 查找物料名称
var item = null;
(_pendingItems || []).forEach(function(it) {
if (it.product_code === productCode) item = it;
});
var bomList = [];
if (item && item.bom_items) {
try { bomList = typeof item.bom_items === 'string' ? JSON.parse(item.bom_items) : item.bom_items; } catch(e) {}
}
var matName = '';
bomList.forEach(function(b) {
if (b.material_code === materialCode) matName = b.material_name;
});
// 调用后端生成物料采购订单
fetch('/api/purchase/create_material_order', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
product_code: productCode,
material_code: materialCode,
material_name: matName,
order_qty: qty
})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
alert('✅ 物料采购单已生成: ' + res.order_no);
} else {
alert('❌ ' + (res.msg || '生成失败'));
}
});
}
function updateFormalQty(productCode, val) {
var qty = parseInt(val) || 0;
fetch('/api/purchase/add_pending', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode, formal_order_qty: qty})
});
}
function pendingDetail(productCode) {
var item = null;
_pendingItems.forEach(function(i) {
if (i.product_code === productCode) item = i;
});
if (!item) {
showProductDetail(productCode);
return;
}
var old = document.getElementById('pendingDetailModal');
if (old) old.remove();
var modal = document.createElement('div');
modal.id = 'pendingDetailModal';
modal.className = 'modal-overlay';
modal.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:99999;align-items:flex-start;justify-content:center;padding:40px 0;overflow-y:auto';
modal.onclick = function(e) { if (e.target === modal) {} };
var html = '<div class="modal" style="width:95vw;max-width:1200px"><div class="modal-header"><h3>📋 待下单详情</h3><span class="modal-close" onclick="document.getElementById(\'pendingDetailModal\').remove()">&times;</span></div>';
html += '<div class="modal-body" style="padding:12px 16px;max-height:70vh;overflow-y:auto">';
// 成品信息卡片
html += '<div class="pd-card" style="margin-bottom:10px">';
html += '<div class="pd-card-header"><span>🏭 成品信息</span></div>';
html += '<div class="pd-card-body" style="padding:10px 14px">';
html += '<div class="pd-fields pd-fields-3col" id="pendingProdFields">';
html += '<div style="text-align:center;padding:20px;color:var(--text-light)">加载中...</div>';
html += '</div></div></div>';
// 包材信息卡片
html += '<div class="pd-card">';
html += '<div class="pd-card-header"><span>📦 包材信息</span></div>';
html += '<div class="pd-card-body" style="padding:0;overflow-x:auto;min-height:60px">';
html += '<div id="pendingBomContent" style="text-align:center;padding:20px;color:var(--text-light)">加载中...</div>';
html += '</div></div>';
html += '</div>';
html += '<div class="modal-footer" style="display:flex;justify-content:space-between;align-items:center">';
html += '<div><button class="btn btn-primary" onclick="saveDetailBom(\'' + escHtml(productCode) + '\')">💾 保存</button></div>';
html += '<div><button class="btn" onclick="document.getElementById(\'pendingDetailModal\').remove()">关闭</button></div></div></div>';
modal.innerHTML = html;
document.body.appendChild(modal);
// 并行加载规划数据 + BOM
Promise.all([
fetch('/api/product/planning_detail?code=' + encodeURIComponent(productCode)).then(function(r) { return r.json(); }),
fetch('/api/product/detail_with_bom?code=' + encodeURIComponent(productCode)).then(function(r) { return r.json(); })
]).then(function(results) {
var planRes = results[0];
var bomRes = results[1];
// 成品信息字段
var p = planRes.code === 0 ? planRes.data : {};
var prodFieldsHtml = '';
var fields = [
{label:'产品类型', val: item.product_type || '-'},
{label:'货品编号', val: item.product_code},
{label:'货品名称', val: item.product_name || '-'},
{label:'昨日销量', val: p['昨日销量'] !== undefined ? p['昨日销量'] : '-'},
{label:'库存', val: p['库存'] !== undefined ? p['库存'] : '-'},
{label:'加工厂库存', val: p['加工厂库存'] !== undefined ? p['加工厂库存'] : '-'},
{label:'生产在途', val: p['生产在途'] !== undefined ? p['生产在途'] : '-'},
{label:'采购在途', val: p['采购在途'] !== undefined ? p['采购在途'] : '-'},
{label:'总支撑天数', val: item.total_support_days !== undefined ? item.total_support_days : '-'},
{label:'建议下单数量', val: item.suggested_qty || 0, readonly: true},
{label:'实际下单数量', val: item.formal_order_qty || 0, editable: true},
{label:'下单备注', val: item.remark || '-', editable: true}
];
fields.forEach(function(f) {
var valHtml = escHtml(String(f.val));
if (f.editable) {
valHtml = '<input type="text" class="form-input" style="width:100%;box-sizing:border-box;padding:3px 6px;font-size:13px" value="' + valHtml + '" onchange="updatePendingProdField(\'' + item.product_code + '\',\'' + f.label + '\',this.value)">';
} else if (f.readonly) {
valHtml = '<span style="font-weight:600;color:#8c8c8c">' + valHtml + '</span>';
} else {
valHtml = '<span style="font-weight:600">' + valHtml + '</span>';
}
prodFieldsHtml += '<div class="pd-row pd-row-3col"><span class="pd-label">' + f.label + '</span><span class="pd-value">' + valHtml + '</span></div>';
});
var pf = document.getElementById('pendingProdFields');
if (pf) pf.innerHTML = prodFieldsHtml;
// BOM
var container = document.getElementById('pendingBomContent');
if (!container) return;
var bom = (bomRes.code === 0 && bomRes.data && bomRes.data.bom_materials) ? bomRes.data.bom_materials : [];
if (bom.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-light)">暂无包材物料清单</div>';
return;
}
var formalQty = item.formal_order_qty || 0;
// 读取已保存的包材实际下单数量
var savedBomActuals = {};
var savedBomRemarks = {};
var savedBomManuscript = {};
if (item.bom_items) {
try {
var savedBomList = typeof item.bom_items === 'string' ? JSON.parse(item.bom_items) : item.bom_items;
savedBomList.forEach(function(s) {
if (s.material_code) {
if (s.actual_quantity) savedBomActuals[s.material_code] = s.actual_quantity;
if (s.remark) savedBomRemarks[s.material_code] = s.remark;
if (s.change_manuscript) savedBomManuscript[s.material_code] = s.change_manuscript;
}
});
} catch(e) {}
}
var cellBorder = 'border:1px solid #d9d9d9';
var thBase = 'padding:7px 8px;' + cellBorder + ';text-align:center;white-space:nowrap';
var tableHtml = '<table style="font-size:12px;width:100%;border-collapse:collapse"><thead><tr style="background:#555;color:#fff">' +
'<th style="' + thBase + '">物料编号</th>' +
'<th style="' + thBase + '">物料名称</th>' +
'<th style="' + thBase + '">用量</th>' +
'<th style="' + thBase + '">建议下单数量</th>' +
'<th style="' + thBase + '">实际下单数量</th>' +
'<th style="' + thBase + '">下单前包材</th>' +
'<th style="' + thBase + '">下单后包材</th>' +
'<th style="' + thBase + '">下单备注</th>' +
'<th style="' + thBase + '">更换稿件</th>' +
'<th style="' + thBase + '">操作</th>' +
'</tr></thead><tbody>';
var tdBase = 'padding:7px 8px;' + cellBorder + ';text-align:left';
bom.forEach(function(b) {
var matCode = escHtml(b.material_code || '');
var qty = b.bom_qty || 0;
var orderQty = Math.ceil(formalQty * qty);
// 使用已保存的实际下单数量(如有)
var actualQty = savedBomActuals[b.material_code] || orderQty;
tableHtml += '<tr>' +
'<td style="' + tdBase + ';font-weight:600"><code style="font-weight:700;font-size:13px;color:#333">' + matCode + '</code></td>' +
'<td style="' + tdBase + ';font-weight:600;color:#333">' + escHtml(b.material_name || '') + '</td>' +
'<td style="' + tdBase + '"><input type="number" class="form-input" style="width:60px;text-align:center;font-size:12px;padding:3px 4px;background:#f5f5f5;color:#333;font-weight:600" value="' + qty + '" readonly></td>' +
'<td style="' + tdBase + ';font-weight:600;color:#1890ff">' + orderQty + '</td>' +
'<td style="' + tdBase + '"><input type="number" class="form-input pend-detail-editable" style="width:70px;text-align:center;font-size:12px;padding:3px 4px;font-weight:600;color:#d4380d" value="' + actualQty + '" data-material="' + matCode + '" data-col="actual_qty"></td>' +
'<td style="' + tdBase + '"><input type="number" class="form-input pend-detail-editable" style="width:60px;text-align:center;font-size:12px;padding:3px 4px" value="0" data-material="' + matCode + '" data-col="pre_pack"></td>' +
'<td style="' + tdBase + '"><input type="number" class="form-input pend-detail-editable" style="width:60px;text-align:center;font-size:12px;padding:3px 4px" value="0" data-material="' + matCode + '" data-col="post_pack"></td>' +
'<td style="' + tdBase + '"><input type="text" class="form-input" style="width:100%;min-width:180px;box-sizing:border-box;text-align:left;font-size:12px;padding:3px 4px" value="' + escHtml(savedBomRemarks[b.material_code] || '') + '"></td>' +
'<td style="' + tdBase + '"><select class="form-input bom-manuscript" style="width:55px;text-align:center;font-size:12px;padding:3px 4px" data-material="' + matCode + '"><option value="">否</option><option value="是">是</option></select></td>' +
'<td style="' + tdBase + '"><button class="btn btn-sm" style="background:#e6fffb;color:#13c2c2;border:1px solid #87e8de;font-size:11px;padding:2px 8px">生成</button></td>' +
'</tr>';
});
tableHtml += '</tbody></table>';
container.innerHTML = tableHtml;
// 恢复已保存的更换稿件状态
container.querySelectorAll('.bom-manuscript').forEach(function(sel) {
var mc = sel.getAttribute('data-material');
if (savedBomManuscript[mc]) {
sel.value = savedBomManuscript[mc];
}
});
// 可编辑输入框:回车保存当前项并跳转到下一项
var editableInputs = container.querySelectorAll('.pend-detail-editable');
// 按 data-col 分组,同列内从上往下跳转
var colGroups = {};
editableInputs.forEach(function(inp) {
var col = inp.getAttribute('data-col');
if (!colGroups[col]) colGroups[col] = [];
colGroups[col].push(inp);
});
editableInputs.forEach(function(inp) {
inp.addEventListener('focus', function() { this.select(); });
inp.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
inp.style.borderColor = '#52c41a';
setTimeout(function() { inp.style.borderColor = ''; }, 600);
var col = inp.getAttribute('data-col');
var group = colGroups[col] || [];
var idx = group.indexOf(inp);
var next = group[idx + 1];
if (next) {
next.focus();
next.select();
}
}
});
});
});
}
// 详情弹窗:保存BOM编辑数据
function saveDetailBom(productCode) {
var modal = document.getElementById('pendingDetailModal');
if (!modal) return;
var inputs = modal.querySelectorAll('.pend-detail-editable');
var materials = [];
var seen = {};
inputs.forEach(function(inp) {
var matCode = inp.getAttribute('data-material');
var col = inp.getAttribute('data-col');
var val = inp.value.trim();
if (!matCode) return;
if (!seen[matCode]) {
seen[matCode] = {material_code: matCode};
materials.push(seen[matCode]);
}
if (col === 'actual_qty') seen[matCode].actual_quantity = parseInt(val) || 0;
else if (col === 'pre_pack') seen[matCode].pre_pack_qty = parseFloat(val) || 0;
else if (col === 'post_pack') seen[matCode].post_pack_qty = parseFloat(val) || 0;
else if (col === 'price') seen[matCode].unit_price = parseFloat(val) || 0;
});
// 只保存实际下单数量>0的物料
var toSave = materials.filter(function(m) { return m.actual_quantity > 0; });
if (toSave.length === 0) {
alert('没有实际下单数量>0的物料');
return;
}
// 从 _pendingItems 获取当前数据,避免保存时其他字段被清空
// 优先从DOM读取最新值(用户可能在表行或详情弹窗中修改过)
var curItem = null;
(_pendingItems || []).forEach(function(it) {
if (it.product_code === productCode) curItem = it;
});
// 从表格行输入框读取最新备注(最可靠来源)
var tableRemarkInput = document.querySelector('input[data-pcode="' + productCode + '"]');
var latestRemark = tableRemarkInput ? tableRemarkInput.value : (curItem ? (curItem.remark || '') : '');
// 从表格行输入框读取最新正式下单数量
// 在pendingTable中,正式下单数量的input没有data属性,只能从缓存获取
var saveData = {
product_code: productCode,
product_name: curItem ? curItem.product_name : '',
product_type: curItem ? curItem.product_type : '',
product_grade: curItem ? curItem.product_grade : '',
supplier_name: curItem ? curItem.supplier_name : '',
lead_time: curItem ? curItem.lead_time : 0,
logistics_time: curItem ? curItem.logistics_time : 0,
total_support_days: curItem ? curItem.total_support_days : 0,
procurement_follower: curItem ? curItem.procurement_follower : '',
procurement_planner: curItem ? curItem.procurement_planner : '',
platform: curItem ? curItem.platform : '',
operator: curItem ? curItem.operator : '',
formal_order_qty: curItem ? curItem.formal_order_qty : 0,
suggested_qty: curItem ? curItem.suggested_qty : 0,
actual_order_qty: curItem ? curItem.actual_order_qty : 0,
audit_price: curItem ? curItem.audit_price : 0,
remark: latestRemark,
bom_items: JSON.stringify(toSave)
};
fetch('/api/purchase/add_pending', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(saveData)
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
if (modal) modal.remove();
loadPendingList();
alert('✅ 包材数据已保存');
} else {
alert('❌ ' + (res.msg || '保存失败'));
}
})
.catch(function() { alert('❌ 网络错误'); });
}
// 成品信息字段更新(附带所有字段,防止ON DUPLICATE KEY UPDATE清零其他字段)
window.updatePendingProdField = function(productCode, field, val) {
var curItem = null;
(_pendingItems || []).forEach(function(it) {
if (it.product_code === productCode) curItem = it;
});
var saveData = {
product_code: productCode,
product_name: curItem ? curItem.product_name : '',
product_type: curItem ? curItem.product_type : '',
product_grade: curItem ? curItem.product_grade : '',
supplier_name: curItem ? curItem.supplier_name : '',
lead_time: curItem ? curItem.lead_time : 0,
logistics_time: curItem ? curItem.logistics_time : 0,
total_support_days: curItem ? curItem.total_support_days : 0,
procurement_follower: curItem ? curItem.procurement_follower : '',
procurement_planner: curItem ? curItem.procurement_planner : '',
platform: curItem ? curItem.platform : '',
operator: curItem ? curItem.operator : '',
formal_order_qty: curItem ? curItem.formal_order_qty : 0,
suggested_qty: curItem ? curItem.suggested_qty : 0,
actual_order_qty: curItem ? curItem.actual_order_qty : 0,
audit_price: curItem ? curItem.audit_price : 0,
remark: curItem ? (curItem.remark || '') : ''
};
// 覆盖当前修改的字段
if (field === '实际下单数量') {
saveData.formal_order_qty = parseFloat(val) || 0;
} else if (field === '下单备注') {
saveData.remark = val;
}
fetch('/api/purchase/add_pending', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(saveData)
}).then(function(r){return r.json()}).then(function(res){
if (res.code !== 0) console.warn('更新失败:', res.msg);
}).catch(function(e){ console.warn('更新出错:', e); });
};
function removePending(productCode) {
showCenteredConfirm('回退确认', '确认从待下单列表移除 <strong>[' + escHtml(productCode) + ']</strong>?').then(function(confirmed) {
if (!confirmed) return;
fetch('/api/purchase/remove_pending', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_codes: [productCode]})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
loadPendingList();
} else {
showCenteredAlert('操作失败', '❌ ' + (res.msg || '操作失败'));
}
});
});
}
function updateAuditPrice(productCode, val) {
var price = parseFloat(val) || 0;
fetch('/api/purchase/add_pending', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode, audit_price: price})
});
}
// ─── 采购订单-下单备份管理 ───
var _backupItems = [];
function loadPurchaseReadyPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>下单备份管理</h1>
</div>
<div class="card">
<div class="card-header" style="display:flex;align-items:center;gap:12px">
<h3>📋 已下单记录</h3>
<span style="font-size:13px;color:var(--text-light)" id="backupCount"></span>
<div style="margin-left:auto;display:flex;gap:8px">
<button class="btn btn-sm btn-outline" onclick="loadBackupList()">🔄 刷新</button>
<button class="btn btn-sm" onclick="openBackupColModal()">⚙ 字段设置</button>
</div>
</div>
<div class="card-body" style="overflow-x:auto">
<table class="data-table" id="backupTable">
<thead>
<tr>
<th style="width:40px;text-align:center">序号</th>
<th>产品类型</th>
<th>货品编号</th>
<th>货品名称</th>
<th>下单时间</th>
<th>产品分级</th>
<th>供应商名称</th>
<th>下单备注</th>
<th>货期</th>
<th>物流</th>
<th>总支撑天数</th>
<th>采购跟单</th>
<th>采购计划</th>
<th>平台</th>
<th>运营</th>
<th>下单人</th>
<th>审核价格</th>
<th>建议下单数量</th>
<th>正式下单数量</th>
<th>加入时间</th>
<th class="col-action" style="width:130px;min-width:130px;position:sticky;right:0;background:var(--bg,#fff);z-index:3;border-left:1px solid var(--border)">操作</th>
</tr>
</thead>
<tbody id="backupTbody"></tbody>
</table>
</div>
</div>
`;
loadBackupList();
if (!window._pageReloaders) window._pageReloaders = {};
if (!window._pageDataCache) window._pageDataCache = {};
window._pageReloaders['purchase_ready'] = function() { loadBackupList(); };
window._pageDataCache['purchase_ready'] = { _ts: Date.now() };
}
function loadBackupList() {
var tbody = document.getElementById('backupTbody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="21" style="text-align:center;padding:30px;color:var(--text-light)"><div class="loading-spinner"></div></td></tr>';
fetch('/api/purchase/pending_list?status=ordered')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
_backupItems = res.data || [];
var countEl = document.getElementById('backupCount');
if (countEl) countEl.textContent = '共 ' + _backupItems.length + ' 条';
if (_backupItems.length === 0) {
tbody.innerHTML = '<tr><td colspan="21" style="text-align:center;padding:60px;color:var(--text-light)">暂无已下单记录 ✅</td></tr>';
return;
}
tbody.innerHTML = _backupItems.map(function(item, idx) {
var code = escHtml(item.product_code);
return '<tr>' +
'<td style="text-align:center">' + (idx + 1) + '</td>' +
'<td>' + escHtml(item.product_type || '-') + '</td>' +
'<td><code>' + code + '</code></td>' +
'<td>' + escHtml(item.product_name || '-') + '</td>' +
'<td>' + ((item.ordered_at || '').toString().slice(0, 19) || '-') + '</td>' +
'<td>' + escHtml(item.product_grade || '-') + '</td>' +
'<td>' + escHtml(item.supplier_name || '-') + '</td>' +
'<td>' + escHtml(item.remark || '-') + '</td>' +
'<td>' + (item.lead_time || 0) + '</td>' +
'<td>' + (item.logistics_time || 0) + '</td>' +
'<td>' + (item.total_support_days || 0) + '</td>' +
'<td>' + renderNameBadges(item.procurement_follower) + '</td>' +
'<td>' + renderNameBadges(item.procurement_planner) + '</td>' +
'<td>' + escHtml(item.platform || '-') + '</td>' +
'<td>' + renderNameBadges(item.operator) + '</td>' +
'<td>' + escHtml(item.created_by || '-') + '</td>' +
'<td>' + (item.audit_price || 0) + '</td>' +
'<td>' + (item.suggested_qty || 0) + '</td>' +
'<td>' + (item.formal_order_qty || 0) + '</td>' +
'<td style="font-size:12px;color:var(--text-light)">' + ((item.created_at || '').toString().slice(0, 19) || '-') + '</td>' +
'<td style="position:sticky;right:0;background:var(--card-bg,#fff);border-left:1px solid var(--border);white-space:nowrap">' +
'<button class="btn btn-sm btn-outline" onclick="viewBackupDetail(\'' + code + '\')" title="详情" style="font-size:11px">详情</button> ' +
'<button class="btn btn-sm btn-outline-danger" onclick="showBackupRollbackConfirm(\'' + code + '\')" title="回退" style="font-size:11px">⏪ 回退</button> ' +
'<button class="btn btn-sm btn-outline-danger" onclick="deleteBackup(\'' + code + '\')" title="删除" style="font-size:11px">删除</button>' +
'</td>' +
'</tr>';
}).join('');
});
}
// ─── 下单备份管理:详情+删除 ───
window._backupItems = [];
window.viewBackupDetail = function(code) {
var item = null;
(_backupItems || []).forEach(function(it) {
if (it.product_code === code) item = it;
});
if (!item) { alert('未找到数据'); return; }
var fields = [
['产品类型', item.product_type],
['货品编号', item.product_code],
['货品名称', item.product_name],
['产品分级', item.product_grade],
['供应商名称', item.supplier_name],
['货期(天)', item.lead_time],
['物流(天)', item.logistics_time],
['总支撑天数', item.total_support_days],
['采购跟单', item.procurement_follower],
['采购计划', item.procurement_planner],
['归属平台', item.platform],
['运营专员', item.operator],
['审核价格', item.audit_price],
['正式下单数量', item.formal_order_qty],
['建议下单数量', item.suggested_qty],
['下单备注', item.remark],
['下单时间', (item.ordered_at || '').toString().slice(0, 19)],
['加入时间', (item.created_at || '').toString().slice(0, 19)]
];
var html = '<div style="padding:24px 24px 8px">';
html += '<h3 style="margin-bottom:16px;font-size:17px">📋 下单详情 - ' + escHtml(code) + '</h3>';
html += '<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden">';
html += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
fields.forEach(function(f, i) {
var bg = i % 2 === 0 ? '#fafafa' : '#fff';
html += '<tr style="background:' + bg + '">';
html += '<td style="padding:8px 12px;color:var(--text-secondary);font-weight:500;width:100px;border-bottom:1px solid #f0f0f0">' + f[0] + '</td>';
html += '<td style="padding:8px 12px;border-bottom:1px solid #f0f0f0;font-weight:600">' + escHtml(String(f[1] || '-')) + '</td>';
html += '</tr>';
});
html += '</table></div></div>';
html += '<div style="padding:16px 24px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:10px">';
html += '<button class="btn" onclick="this.parentElement.parentElement.parentElement.remove()">关闭</button></div>';
var overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = '<div style="background:#fff;border-radius:12px;width:520px;max-width:90vw;max-height:80vh;overflow-y:auto;box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:20px">' + html + '</div>';
document.body.appendChild(overlay);
};
// 下单回退:确认弹窗 + 调用后端
window.showBackupRollbackConfirm = function(code) {
showCenteredConfirm('回退确认',
'确认将 <strong>[' + escHtml(code) + ']</strong> 回退到待下单列表?<br>' +
'将从国内/海外订单、包材订单中同步删除对应数据。'
).then(function(confirmed) {
if (!confirmed) return;
fetch('/api/purchase/rollback_order', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: code})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) {
loadBackupList();
} else {
alert('回退失败: ' + (res.msg || ''));
}
});
});
};
window.deleteBackup = function(code) {
showCenteredConfirm('删除确认', '确认删除此记录?<br><strong>编码: ' + escHtml(code) + '</strong>').then(function(confirmed) {
if (!confirmed) return;
fetch('/api/purchase/remove_pending', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_codes: [code]})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) {
loadBackupList();
} else {
showCenteredAlert('删除失败', '删除失败: ' + (res.msg || ''));
}
});
});
};
// ─── 采购订单-订单管理(国内/海外/包材)───
// ──────────── 国内订单管理 ────────────
var DOMESTIC_PAGE_SIZE = 100;
var domesticOrderPage = 1;
var domesticSearch = '';
var domesticFilterStatus = '';
var domesticFilterSupplier = '';
var domesticFilterOrderer = '';
var domesticFrozen = localStorage.getItem('domesticFrozen') !== 'false';
function loadDomesticOrdersPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>国内订单管理</h1>
<div style="display:flex;gap:10px;margin-top:8px;flex-wrap:wrap;align-items:center">
<div class="search-box" style="max-width:300px">
<input type="text" id="domesticSearchInput" class="form-input" placeholder="搜索编码/名称/供货商..."
onkeydown="if(event.key==='Enter') domesticDoSearch()" style="padding-left:32px;padding-right:8px;height:32px;font-size:13px">
<svg style="position:absolute;left:8px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:#999" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</div>
<select id="domesticSupplierFilter" class="form-input" style="width:auto;min-width:120px;height:32px;font-size:13px" onchange="domesticApplyFilters()">
<option value="">全部供货商</option>
</select>
<select id="domesticStatusFilter" class="form-input" style="width:auto;min-width:120px;height:32px;font-size:13px" onchange="domesticApplyFilters()">
<option value="">全部状态</option>
</select>
<select id="domesticOrdererFilter" class="form-input" style="width:auto;min-width:100px;height:32px;font-size:13px" onchange="domesticApplyFilters()">
<option value="">全部下单人</option>
</select>
<button class="btn btn-sm btn-primary" onclick="domesticDoSearch()">查询</button>
<button class="btn btn-sm btn-outline" onclick="domesticResetFilters()">重置</button>
<span id="domesticTotalInfo" style="font-size:13px;color:var(--text-light);margin-left:auto"></span>
</div>
</div>
<div id="domesticContent">
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
<h3>📦 国内采购下单明细</h3>
<div>
<button class="btn btn-sm btn-primary" onclick="domesticAddRow()">+ 新增</button>
<button class="btn btn-sm btn-outline" onclick="domesticExport()">导出</button>
<button class="btn btn-sm btn-outline" onclick="domesticToggleFrozen()">🔒 冻结列</button>
<button class="btn btn-sm btn-outline" onclick="openDomesticColModal()">⚙ 字段设置</button>
</div>
</div>
<div class="card-body" style="padding:0;overflow:auto">
<table class="data-table" id="domesticOrdersTable" style="font-size:12px;white-space:nowrap;min-width:6000px">
<thead style="position:sticky;top:0;z-index:10;background:var(--bg,#f5f5f5)"><tr id="domesticOrdersHeader">
<th style="position:sticky;left:0;z-index:3;background:var(--bg,#f5f5f5);min-width:50px">#</th>
<th style="position:sticky;left:50px;z-index:3;background:var(--bg,#f5f5f5);min-width:100px">日期</th>
<th style="min-width:140px;position:sticky;left:150px;top:0;z-index:11;background:var(--bg,#f5f5f5)">计划下单时间</th>
<th style="min-width:60px;position:sticky;left:290px;top:0;z-index:11;background:var(--bg,#f5f5f5)">新旧品</th>
<th style="min-width:120px;position:sticky;left:350px;top:0;z-index:11;background:var(--bg,#f5f5f5)">编码</th>
<th style="min-width:200px;position:sticky;left:470px;top:0;z-index:11;background:var(--bg,#f5f5f5)">名称</th>
<th style="min-width:150px;white-space:normal">下单备注</th>
<th style="min-width:70px">单价</th>
<th style="min-width:80px">审核价格</th>
<th style="min-width:60px">数量</th>
<th style="min-width:80px">金额</th>
<th style="min-width:100px">供货商</th>
<th style="min-width:80px">仓库</th>
<th style="min-width:100px">金蝶采购单</th>
<th style="min-width:150px">采购员</th>
<th style="min-width:70px">下单人</th>
<th style="min-width:80px">进度</th>
<th style="min-width:120px">旺店通生产单号</th>
<th style="min-width:90px">录入旺店通</th>
<th style="min-width:100px">采购开发</th>
<th style="min-width:100px">实际到货日期</th>
<th style="min-width:80px">到货数量辅</th>
<th style="min-width:70px">辅助1</th>
<th style="min-width:80px">实到数量</th>
<th style="min-width:80px">未到货数</th>
<th style="min-width:100px">预计到货日期</th>
<th style="min-width:80px">金蝶入库</th>
<th style="min-width:70px">状态</th>
<th style="min-width:70px">做货周期</th>`;
// 追加1-31列
for (var i = 1; i <= 31; i++) {
container.querySelector('#domesticOrdersHeader').innerHTML += '<th style="min-width:35px;font-size:11px;color:#666">' + i + '</th>';
}
container.querySelector('#domesticOrdersHeader').innerHTML += '<th style="min-width:100px">父记录</th><th style="min-width:100px;position:sticky;right:0;z-index:3;background:var(--bg,#f5f5f5);border-left:1px solid var(--border)">操作</th>';
container.querySelector('#domesticOrdersTable').innerHTML += '</thead><tbody id="domesticOrdersTbody"><tr><td colspan="72" style="text-align:center;padding:40px;color:var(--text-light)">加载中...</td></tr></tbody>';
container.querySelector('#domesticContent').innerHTML += '</div><div id="domesticOrdersPagination" class="card-footer" style="padding:8px 16px;border-top:1px solid var(--border)"></div></div>';
domesticLoadFilters();
domesticLoadOrders(1);
}
function domesticLoadFilters() {
fetch('/api/domestic/orders/filters')
.then(function(r){return r.json()})
.then(function(res){
if (res.code !== 0) return;
var sl = document.getElementById('domesticSupplierFilter');
var ss = document.getElementById('domesticStatusFilter');
var so = document.getElementById('domesticOrdererFilter');
(res.data.suppliers || []).forEach(function(s) {
var opt = document.createElement('option');
opt.value = s; opt.textContent = s;
sl.appendChild(opt);
});
(res.data.statuses || []).forEach(function(s) {
var opt = document.createElement('option');
opt.value = s; opt.textContent = s;
ss.appendChild(opt);
});
(res.data.orderers || []).forEach(function(s) {
var opt = document.createElement('option');
opt.value = s; opt.textContent = s;
so.appendChild(opt);
});
});
}
function domesticDoSearch() {
domesticSearch = document.getElementById('domesticSearchInput').value.trim();
domesticLoadOrders(1);
}
function domesticApplyFilters() {
domesticFilterSupplier = document.getElementById('domesticSupplierFilter').value;
domesticFilterStatus = document.getElementById('domesticStatusFilter').value;
domesticFilterOrderer = document.getElementById('domesticOrdererFilter').value;
domesticLoadOrders(1);
}
function domesticResetFilters() {
document.getElementById('domesticSearchInput').value = '';
document.getElementById('domesticSupplierFilter').value = '';
document.getElementById('domesticStatusFilter').value = '';
document.getElementById('domesticOrdererFilter').value = '';
domesticSearch = '';
domesticFilterSupplier = '';
domesticFilterStatus = '';
domesticFilterOrderer = '';
domesticLoadOrders(1);
}
function domesticLoadOrders(page) {
domesticOrderPage = page;
// 初始化表头冻结状态
(function(){
var h = document.getElementById('domesticOrdersHeader');
if (!h) return;
var s = h.querySelectorAll('th');
var f = domesticFrozen;
if (s[2]) s[2].style.cssText = f ? 'min-width:140px;position:sticky;left:150px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:140px';
if (s[3]) s[3].style.cssText = f ? 'min-width:60px;position:sticky;left:290px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:60px';
if (s[4]) s[4].style.cssText = f ? 'min-width:120px;position:sticky;left:350px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:120px';
if (s[5]) s[5].style.cssText = f ? 'min-width:200px;position:sticky;left:470px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:200px';
})();
var params = '?page=' + page + '&size=' + DOMESTIC_PAGE_SIZE;
if (domesticSearch) params += '&search=' + encodeURIComponent(domesticSearch);
if (domesticFilterStatus) params += '&status=' + encodeURIComponent(domesticFilterStatus);
if (domesticFilterSupplier) params += '&supplier=' + encodeURIComponent(domesticFilterSupplier);
if (domesticFilterOrderer) params += '&orderer=' + encodeURIComponent(domesticFilterOrderer);
fetch('/api/domestic/orders/list' + params)
.then(function(r){return r.json()})
.then(function(res){
if (res.code !== 0) return;
var data = res.data;
var tbody = document.getElementById('domesticOrdersTbody');
var totalInfo = document.getElementById('domesticTotalInfo');
if (totalInfo) totalInfo.textContent = '共 ' + data.total + ' 条';
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="72" style="text-align:center;padding:60px;color:var(--text-light)">暂无数据</td></tr>';
} else {
var startIdx = (page - 1) * DOMESTIC_PAGE_SIZE;
var html = '';
data.rows.forEach(function(r, i) {
var rowNum = startIdx + i + 1;
var fields = [
'日期','计划下单时间','新旧品','编码','名称','下单备注','单价','是否审核价格','数量','金额',
'供货商','仓库','金蝶采购单','采购员','下单人','进度','旺店通生产单号','是否已录入旺店通',
'采购开发','实际到货日期','实际到货数量辅助','辅助1','实际到货数量','未到货数','预计到货日期',
'金蝶入库','状态'
];
html += '<tr>';
html += '<td style="position:sticky;left:0;z-index:2;background:var(--card-bg,#fff);text-align:center;color:#999">' + rowNum + '</td>';
// 日期
html += '<td style="position:sticky;left:50px;z-index:2;background:var(--card-bg,#fff)"><span class="date-text">' + escHtml(r['日期'] || '') + '</span> <span class="date-edit-btn" onclick="event.stopPropagation();editDateField(this.parentNode,' + r.id + ',\'日期\',\'/api/domestic/orders/update\')" style="cursor:pointer;color:#1890ff;font-size:12px">📅</span></td>';
// 主要字段
html += '<td style="' + (domesticFrozen ? 'position:sticky;left:150px;z-index:2;background:var(--card-bg,#fff)' : '') + '">' + escHtml(r['计划下单时间'] || '') + '</td>';
html += '<td style="' + (domesticFrozen ? 'position:sticky;left:290px;z-index:2;background:var(--card-bg,#fff)' : '') + '">' + escHtml(r['新旧品'] || '') + '</td>';
html += '<td style="' + (domesticFrozen ? 'position:sticky;left:350px;z-index:2;background:var(--card-bg,#fff)' : '') + '"><code>' + escHtml(r['编码'] || '') + '</code></td>';
html += '<td style="' + (domesticFrozen ? 'position:sticky;left:470px;z-index:2;background:var(--card-bg,#fff);max-width:250px;overflow:hidden;text-overflow:ellipsis' : 'max-width:250px;overflow:hidden;text-overflow:ellipsis') + '" title="' + escHtml(r['名称'] || '') + '">' + escHtml(r['名称'] || '') + '</td>';
html += '<td title="' + escHtml(r['下单备注'] || '') + '" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(r['下单备注'] || '-') + '</td>';
html += '<td style="padding:2px 4px"><input type="text" class="price-inline" value="' + (r['单价'] ? parseFloat(r['单价']).toFixed(2) : '') + '" data-pid="' + r.id + '" data-pfield="单价" data-papi="/api/domestic/orders/update" style="width:70px;border:1px solid #d9d9d9;border-radius:3px;padding:2px 4px;font-size:12px;text-align:right;background:var(--bg,#fff);color:var(--text,#333)"></td>';
html += '<td>' + escHtml(r['是否审核价格'] || '') + '</td>';
html += '<td><strong>' + (parseInt(r['数量']) || 0).toLocaleString() + '</strong></td>';
html += '<td>' + (r['金额'] ? '¥' + parseFloat(r['金额']).toLocaleString() : '') + '</td>';
html += '<td>' + escHtml(r['供货商'] || '') + '</td>';
html += '<td>' + escHtml(r['仓库'] || '') + '</td>';
html += '<td><code>' + escHtml(r['金蝶采购单'] || '') + '</code></td>';
html += '<td style="font-size:11px">' + renderNameBadges(r['采购员']) + '</td>';
html += '<td>' + renderNameBadges(r['下单人']) + '</td>';
html += '<td>' + escHtml(r['进度'] || '') + '</td>';
html += '<td><code>' + escHtml(r['旺店通生产单号'] || '') + '</code></td>';
html += '<td>' + escHtml(r['是否已录入旺店通'] || '') + '</td>';
html += '<td>' + renderNameBadges(r['采购开发']) + '</td>';
html += '<td>' + escHtml(r['实际到货日期'] || '') + '</td>';
html += '<td>' + (parseInt(r['实际到货数量辅助']) || '').toLocaleString() + '</td>';
html += '<td>' + escHtml(r['辅助1'] || '') + '</td>';
html += '<td><strong>' + (parseInt(r['实际到货数量']) || '').toLocaleString() + '</strong></td>';
html += '<td><strong>' + (parseInt(r['未到货数']) || '').toLocaleString() + '</strong></td>';
html += '<td>' + escHtml(r['预计到货日期'] || '') + '</td>';
html += '<td>' + escHtml(r['金蝶入库'] || '') + '</td>';
html += '<td><span class="badge">' + escHtml(r['状态'] || '') + '</span></td>';
// 做货周期 = 实际到货日期 - 日期
var manuDays = '';
if (r['实际到货日期'] && r['日期']) {
var d1 = new Date(r['实际到货日期'].replace(/-/g, '/'));
var d2 = new Date(r['日期'].replace(/-/g, '/'));
if (!isNaN(d1) && !isNaN(d2)) {
var diff = Math.round((d1 - d2) / 86400000);
if (diff >= 0) manuDays = diff + '天';
}
}
html += '<td style="text-align:center;font-weight:600">' + manuDays + '</td>';
// 1-31列
for (var d = 1; d <= 31; d++) {
var v = parseInt(r[String(d)]) || 0;
html += '<td style="text-align:right;font-size:11px">' + (v > 0 ? v.toLocaleString() : '') + '</td>';
}
html += '<td style="font-size:11px">' + escHtml(r['父记录'] || '') + '</td>';
html += '<td style="position:sticky;right:0;z-index:2;background:var(--card-bg,#fff);border-left:1px solid var(--border)">';
html += '<button class="btn btn-sm btn-outline" onclick="domesticEditRow(' + r.id +')" title="编辑" style="font-size:11px">✏️</button> ';
html += '<button class="btn btn-sm btn-outline" onclick="generateContractForOrders(\'domestic\',[' + r.id + '])" title="生成合同" style="font-size:11px;color:#52c41a;border-color:#52c41a">生成合同</button> ';
html += '<button class="btn btn-sm btn-outline-danger" onclick="domesticDeleteRow(' + r.id +')" title="删除" style="font-size:11px">🗑️</button>';
html += '</td>';
html += '</tr>';
});
tbody.innerHTML = html;
}
var pel = document.getElementById('domesticOrdersPagination');
if (pel) {
if (typeof renderPaginationWithSize === 'function') {
renderPaginationWithSize(pel, page, data.total || 0, DOMESTIC_PAGE_SIZE, 'domesticLoadOrders', 'DOMESTIC_PAGE_SIZE');
} else {
// 简单分页
var totalPages = Math.ceil((data.total || 0) / DOMESTIC_PAGE_SIZE);
pel.innerHTML = '<div style="display:flex;gap:5px;align-items:center;justify-content:center">';
var phtml = '';
if (page > 1) phtml += '<button class="btn btn-sm" onclick="domesticLoadOrders(' + (page-1) + ')">上一页</button>';
phtml += '<span style="padding:0 10px;font-size:13px">第 ' + page + '/' + totalPages + ' 页</span>';
if (page < totalPages) phtml += '<button class="btn btn-sm" onclick="domesticLoadOrders(' + (page+1) + ')">下一页</button>';
pel.innerHTML += phtml + '</div>';
}
}
});
}
window.domesticEditRow = function(id) {
// 简单编辑弹窗
var row = document.querySelector('#domesticOrdersTbody tr:nth-child(' + (id) + ')');
var newVal = prompt('输入新值(格式: 字段名=值),例如: 状态=已完结');
if (!newVal) return;
var parts = newVal.split('=');
if (parts.length < 2) { alert('格式错误,请用: 字段名=值'); return; }
var field = parts[0].trim();
var value = parts.slice(1).join('=').trim();
fetch('/api/domestic/orders/update', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id, field: field, value: value})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) { domesticLoadOrders(domesticOrderPage); }
else { alert(res.msg || '更新失败'); }
});
};
window.domesticDeleteRow = function(id) {
if (!confirm('确认删除此记录?')) return;
fetch('/api/domestic/orders/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) { domesticLoadOrders(domesticOrderPage); }
else { alert(res.msg || '删除失败'); }
});
};
window.domesticAddRow = function() {
var code = prompt('请输入编码(必填):');
if (!code) return;
var name = prompt('请输入名称:');
fetch('/api/domestic/orders/add', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({编码: code, 名称: name || ''})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) { domesticLoadOrders(1); }
else { alert(res.msg || '添加失败'); }
});
};
window.domesticExport = function() {
// 简单 CSV 导出
var tbody = document.getElementById('domesticOrdersTbody');
if (!tbody) return;
var lines = [];
var headers = ['#','日期','计划下单时间','新旧品','编码','名称','下单备注','单价','审核价格','数量','金额','供货商','仓库','金蝶采购单','采购员','下单人','进度','旺店通单号','录入旺店通','采购开发','实际到货日期','到货数量辅','辅助1','实到数量','未到货数','预计到货日期','金蝶入库','状态'];
for (var i = 1; i <= 31; i++) headers.push(i);
headers.push('父记录');
lines.push(headers.join(','));
var rows = tbody.querySelectorAll('tr');
rows.forEach(function(tr) {
var cols = tr.querySelectorAll('td');
var row = [];
cols.forEach(function(td) {
var txt = td.textContent.trim().replace(/,/g, ',').replace(/"/g, '""');
row.push('"' + txt + '"');
});
lines.push(row.join(','));
});
if (lines.length <= 1) return;
var blob = new Blob([lines.join('\n')], {type: 'text/csv;charset=utf-8'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '国内订单_' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
};
// ──────────── 海外订单管理 ────────────
var OVERSEAS_PAGE_SIZE = 100;
var overseasOrderPage = 1;
var overseasSearch = '';
var overseasFilterStatus = '';
var overseasFilterSupplier = '';
var overseasFilterOrderer = '';
var overseasFrozen = localStorage.getItem('overseasFrozen') !== 'false';
function loadOverseasOrdersPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>海外订单管理</h1>
<div style="display:flex;gap:10px;margin-top:8px;flex-wrap:wrap;align-items:center">
<div class="search-box" style="max-width:300px">
<input type="text" id="overseasSearchInput" class="form-input" placeholder="搜索编码/名称/供应商..."
onkeydown="if(event.key==='Enter') overseasDoSearch()" style="padding-left:32px;padding-right:8px;height:32px;font-size:13px">
<svg style="position:absolute;left:8px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:#999" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</div>
<select id="overseasSupplierFilter" class="form-input" style="width:auto;min-width:120px;height:32px;font-size:13px" onchange="overseasApplyFilters()">
<option value="">全部供应商</option>
</select>
<select id="overseasStatusFilter" class="form-input" style="width:auto;min-width:120px;height:32px;font-size:13px" onchange="overseasApplyFilters()">
<option value="">全部状态</option>
</select>
<select id="overseasOrdererFilter" class="form-input" style="width:auto;min-width:100px;height:32px;font-size:13px" onchange="overseasApplyFilters()">
<option value="">全部下单人</option>
</select>
<button class="btn btn-sm btn-primary" onclick="overseasDoSearch()">查询</button>
<button class="btn btn-sm btn-outline" onclick="overseasResetFilters()">重置</button>
<span id="overseasTotalInfo" style="font-size:13px;color:var(--text-light);margin-left:auto"></span>
</div>
</div>
<div id="overseasContent">
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
<h3>🌍 海外采购下单明细</h3>
<div>
<button class="btn btn-sm btn-primary" onclick="overseasAddRow()">+ 新增</button>
<button class="btn btn-sm btn-outline" onclick="overseasExport()">导出</button>
<button class="btn btn-sm btn-outline" onclick="overseasToggleFrozen()">🔒 冻结列</button>
<button class="btn btn-sm btn-outline" onclick="openOverseasColModal()">⚙ 字段设置</button>
</div>
</div>
<div class="card-body" style="padding:0;overflow:auto">
<table class="data-table" id="overseasOrdersTable" style="font-size:12px;white-space:nowrap;min-width:5800px">
<thead style="position:sticky;top:0;z-index:10;background:var(--bg,#f5f5f5)"><tr id="overseasOrdersHeader">
<th style="position:sticky;left:0;z-index:3;background:var(--bg,#f5f5f5);min-width:50px">#</th>
<th style="position:sticky;left:50px;z-index:3;background:var(--bg,#f5f5f5);min-width:100px">日期</th>
<th style="min-width:140px;position:sticky;left:150px;top:0;z-index:11;background:var(--bg,#f5f5f5)">计划下单时间</th>
<th style="min-width:120px;position:sticky;left:290px;top:0;z-index:11;background:var(--bg,#f5f5f5)">产品编码</th>
<th style="min-width:70px">单价</th>
<th style="min-width:80px">审核价格</th>
<th style="min-width:60px">数量</th>
<th style="min-width:80px">金额</th>
<th style="min-width:100px">供应商</th>
<th style="min-width:80px">仓库</th>
<th style="min-width:200px;position:sticky;left:410px;top:0;z-index:11;background:var(--bg,#f5f5f5)">货品名称</th>
<th style="min-width:60px">新旧品</th>
<th style="min-width:90px">已做采购单</th>
<th style="min-width:70px">已入库</th>
<th style="min-width:150px">采购员</th>
<th style="min-width:70px">下单人</th>
<th style="min-width:100px">进度</th>
<th style="min-width:150px;white-space:normal">下单备注</th>
<th style="min-width:100px">采购开发</th>
<th style="min-width:120px">旺店通单号</th>
<th style="min-width:90px">录入旺店通</th>
<th style="min-width:100px">实际到货日期</th>
<th style="min-width:80px">到货数量辅</th>
<th style="min-width:70px">辅助</th>
<th style="min-width:80px">实到数量</th>
<th style="min-width:100px">预计到货日期</th>
<th style="min-width:80px">未到货数</th>
<th style="min-width:70px">状态</th>
<th style="min-width:70px">做货周期</th>`;
for (var i = 1; i <= 31; i++) {
container.querySelector('#overseasOrdersHeader').innerHTML += '<th style="min-width:35px;font-size:11px;color:#666">' + i + '</th>';
}
container.querySelector('#overseasOrdersHeader').innerHTML += '<th style="min-width:100px;position:sticky;right:0;z-index:3;background:var(--bg,#f5f5f5);border-left:1px solid var(--border)">操作</th>';
container.querySelector('#overseasOrdersTable').innerHTML += '</thead><tbody id="overseasOrdersTbody"><tr><td colspan="69" style="text-align:center;padding:40px;color:var(--text-light)">加载中...</td></tr></tbody>';
container.querySelector('#overseasContent').innerHTML += '</div><div id="overseasOrdersPagination" class="card-footer" style="padding:8px 16px;border-top:1px solid var(--border)"></div></div>';
overseasLoadFilters();
overseasLoadOrders(1);
}
function overseasLoadFilters() {
fetch('/api/overseas/orders/filters')
.then(function(r){return r.json()})
.then(function(res){
if (res.code !== 0) return;
var sl = document.getElementById('overseasSupplierFilter');
var ss = document.getElementById('overseasStatusFilter');
var so = document.getElementById('overseasOrdererFilter');
(res.data.suppliers || []).forEach(function(s) {
var opt = document.createElement('option');
opt.value = s; opt.textContent = s;
sl.appendChild(opt);
});
(res.data.statuses || []).forEach(function(s) {
var opt = document.createElement('option');
opt.value = s; opt.textContent = s;
ss.appendChild(opt);
});
(res.data.orderers || []).forEach(function(s) {
var opt = document.createElement('option');
opt.value = s; opt.textContent = s;
so.appendChild(opt);
});
});
}
function overseasDoSearch() {
overseasSearch = document.getElementById('overseasSearchInput').value.trim();
overseasLoadOrders(1);
}
function overseasApplyFilters() {
overseasFilterSupplier = document.getElementById('overseasSupplierFilter').value;
overseasFilterStatus = document.getElementById('overseasStatusFilter').value;
overseasFilterOrderer = document.getElementById('overseasOrdererFilter').value;
overseasLoadOrders(1);
}
function overseasResetFilters() {
document.getElementById('overseasSearchInput').value = '';
document.getElementById('overseasSupplierFilter').value = '';
document.getElementById('overseasStatusFilter').value = '';
document.getElementById('overseasOrdererFilter').value = '';
overseasSearch = '';
overseasFilterSupplier = '';
overseasFilterStatus = '';
overseasFilterOrderer = '';
overseasLoadOrders(1);
}
function overseasLoadOrders(page) {
overseasOrderPage = page;
// 初始化表头冻结状态
(function(){
var h = document.getElementById('overseasOrdersHeader');
if (!h) return;
var s = h.querySelectorAll('th');
var f = overseasFrozen;
if (s[2]) s[2].style.cssText = f ? 'min-width:140px;position:sticky;left:150px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:140px';
if (s[3]) s[3].style.cssText = f ? 'min-width:120px;position:sticky;left:290px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:120px';
if (s[4]) s[4].style.cssText = f ? 'min-width:200px;position:sticky;left:410px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:200px';
})();
var params = '?page=' + page + '&size=' + OVERSEAS_PAGE_SIZE;
if (overseasSearch) params += '&search=' + encodeURIComponent(overseasSearch);
if (overseasFilterStatus) params += '&status=' + encodeURIComponent(overseasFilterStatus);
if (overseasFilterSupplier) params += '&supplier=' + encodeURIComponent(overseasFilterSupplier);
if (overseasFilterOrderer) params += '&orderer=' + encodeURIComponent(overseasFilterOrderer);
fetch('/api/overseas/orders/list' + params)
.then(function(r){return r.json()})
.then(function(res){
if (res.code !== 0) return;
var data = res.data;
var tbody = document.getElementById('overseasOrdersTbody');
var totalInfo = document.getElementById('overseasTotalInfo');
if (totalInfo) totalInfo.textContent = '共 ' + data.total + ' 条';
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="69" style="text-align:center;padding:60px;color:var(--text-light)">暂无数据</td></tr>';
} else {
var startIdx = (page - 1) * OVERSEAS_PAGE_SIZE;
var html = '';
data.rows.forEach(function(r, i) {
var rowNum = startIdx + i + 1;
html += '<tr>';
html += '<td style="position:sticky;left:0;z-index:2;background:var(--card-bg,#fff);text-align:center;color:#999">' + rowNum + '</td>';
html += '<td style="position:sticky;left:50px;z-index:2;background:var(--card-bg,#fff)"><span class="date-text">' + escHtml(r['日期'] || '') + '</span> <span class="date-edit-btn" onclick="event.stopPropagation();editDateField(this.parentNode,' + r.id + ',\'日期\',\'/api/overseas/orders/update\')" style="cursor:pointer;color:#1890ff;font-size:12px">📅</span></td>';
html += '<td style="' + (overseasFrozen ? 'position:sticky;left:150px;z-index:2;background:var(--card-bg,#fff)' : '') + '">' + escHtml(r['计划下单时间'] || '') + '</td>';
html += '<td style="' + (overseasFrozen ? 'position:sticky;left:290px;z-index:2;background:var(--card-bg,#fff)' : '') + '"><code>' + escHtml(r['产品编码'] || '') + '</code></td>';
html += '<td style="padding:2px 4px"><input type="text" class="price-inline" value="' + (r['单价'] ? parseFloat(r['单价']).toFixed(2) : '') + '" data-pid="' + r.id + '" data-pfield="单价" data-papi="/api/overseas/orders/update" style="width:70px;border:1px solid #d9d9d9;border-radius:3px;padding:2px 4px;font-size:12px;text-align:right;background:var(--bg,#fff);color:var(--text,#333)"></td>';
html += '<td>' + escHtml(r['是否审核价格'] || '') + '</td>';
html += '<td><strong>' + (parseInt(r['数量']) || 0).toLocaleString() + '</strong></td>';
html += '<td>' + (r['金额'] ? '¥' + parseFloat(r['金额']).toLocaleString() : '') + '</td>';
html += '<td>' + escHtml(r['供应商'] || '') + '</td>';
html += '<td>' + escHtml(r['仓库'] || '') + '</td>';
html += '<td style="' + (overseasFrozen ? 'position:sticky;left:410px;z-index:2;background:var(--card-bg,#fff);max-width:250px;overflow:hidden;text-overflow:ellipsis' : 'max-width:250px;overflow:hidden;text-overflow:ellipsis') + '" title="' + escHtml(r['货品名称'] || '') + '">' + escHtml(r['货品名称'] || '') + '</td>';
html += '<td>' + escHtml(r['新旧品'] || '') + '</td>';
html += '<td>' + escHtml(r['是否已做采购单'] || '') + '</td>';
html += '<td>' + escHtml(r['是否做入库'] || '') + '</td>';
html += '<td style="font-size:11px">' + renderNameBadges(r['采购员']) + '</td>';
html += '<td>' + renderNameBadges(r['下单人']) + '</td>';
html += '<td>' + escHtml(r['进度'] || '') + '</td>';
html += '<td title="' + escHtml(r['下单备注'] || '') + '" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(r['下单备注'] || '-') + '</td>';
html += '<td>' + renderNameBadges(r['采购开发']) + '</td>';
html += '<td><code>' + escHtml(r['旺店通生产单号'] || '') + '</code></td>';
html += '<td>' + escHtml(r['是否已录入到旺店通'] || '') + '</td>';
html += '<td>' + escHtml(r['实际到货日期'] || '') + '</td>';
html += '<td>' + (parseInt(r['实际到货数量辅助']) || '').toLocaleString() + '</td>';
html += '<td>' + escHtml(r['辅助'] || '') + '</td>';
html += '<td><strong>' + (parseInt(r['实际到货数量']) || '').toLocaleString() + '</strong></td>';
html += '<td>' + escHtml(r['预计到货日期'] || '') + '</td>';
html += '<td><strong>' + (parseInt(r['未到货数']) || '').toLocaleString() + '</strong></td>';
html += '<td><span class="badge">' + escHtml(r['状态'] || '') + '</span></td>';
// 做货周期 = 实际到货日期 - 日期
var manuDaysOv = '';
if (r['实际到货日期'] && r['日期']) {
var d1 = new Date(r['实际到货日期'].replace(/-/g, '/'));
var d2 = new Date(r['日期'].replace(/-/g, '/'));
if (!isNaN(d1) && !isNaN(d2)) {
var diff = Math.round((d1 - d2) / 86400000);
if (diff >= 0) manuDaysOv = diff + '天';
}
}
html += '<td style="text-align:center;font-weight:600">' + manuDaysOv + '</td>';
for (var d = 1; d <= 31; d++) {
var v = parseInt(r[String(d)]) || 0;
html += '<td style="text-align:right;font-size:11px">' + (v > 0 ? v.toLocaleString() : '') + '</td>';
}
html += '<td style="position:sticky;right:0;z-index:2;background:var(--card-bg,#fff);border-left:1px solid var(--border)">';
html += '<button class="btn btn-sm btn-outline" onclick="overseasEditRow(' + r.id +')" title="编辑" style="font-size:11px">✏️</button> ';
html += '<button class="btn btn-sm btn-outline" onclick="generateContractForOrders(\'overseas\',[' + r.id + '])" title="生成合同" style="font-size:11px;color:#52c41a;border-color:#52c41a">生成合同</button> ';
html += '<button class="btn btn-sm btn-outline-danger" onclick="overseasDeleteRow(' + r.id +')" title="删除" style="font-size:11px">🗑️</button>';
html += '</td></tr>';
});
tbody.innerHTML = html;
}
var pel = document.getElementById('overseasOrdersPagination');
if (pel) {
var totalPages = Math.ceil((data.total || 0) / OVERSEAS_PAGE_SIZE);
pel.innerHTML = '<div style="display:flex;gap:5px;align-items:center;justify-content:center">';
if (page > 1) pel.innerHTML += '<button class="btn btn-sm" onclick="overseasLoadOrders(' + (page-1) + ')">上一页</button>';
pel.innerHTML += '<span style="padding:0 10px;font-size:13px">第 ' + page + '/' + totalPages + ' 页</span>';
if (page < totalPages) pel.innerHTML += '<button class="btn btn-sm" onclick="overseasLoadOrders(' + (page+1) + ')">下一页</button>';
pel.innerHTML += '</div>';
}
});
}
window.overseasEditRow = function(id) {
var newVal = prompt('输入新值(格式: 字段名=值),例如: 状态=已完结');
if (!newVal) return;
var parts = newVal.split('=');
if (parts.length < 2) { alert('格式错误,请用: 字段名=值'); return; }
var field = parts[0].trim();
var value = parts.slice(1).join('=').trim();
fetch('/api/overseas/orders/update', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id, field: field, value: value})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) { overseasLoadOrders(overseasOrderPage); }
else { alert(res.msg || '更新失败'); }
});
};
window.overseasDeleteRow = function(id) {
if (!confirm('确认删除此记录?')) return;
fetch('/api/overseas/orders/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) { overseasLoadOrders(overseasOrderPage); }
else { alert(res.msg || '删除失败'); }
});
};
window.overseasAddRow = function() {
var code = prompt('请输入产品编码(必填):');
if (!code) return;
var name = prompt('请输入货品名称:');
fetch('/api/overseas/orders/add', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({'产品编码': code, '货品名称': name || ''})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) { overseasLoadOrders(1); }
else { alert(res.msg || '添加失败'); }
});
};
window.overseasExport = function() {
var tbody = document.getElementById('overseasOrdersTbody');
if (!tbody) return;
var lines = [];
var headers = ['#','日期','计划下单时间','产品编码','单价','审核价格','数量','金额','供应商','仓库','货品名称','新旧品','已做采购单','已入库','采购员','下单人','进度','下单备注','采购开发','旺店通单号','录入旺店通','实际到货日期','到货数量辅','辅助','实到数量','预计到货日期','未到货数','状态'];
for (var i = 1; i <= 31; i++) headers.push(i);
lines.push(headers.join(','));
var rows = tbody.querySelectorAll('tr');
rows.forEach(function(tr) {
var cols = tr.querySelectorAll('td');
var row = [];
cols.forEach(function(td) {
var txt = td.textContent.trim().replace(/,/g, ',').replace(/"/g, '""');
row.push('"' + txt + '"');
});
lines.push(row.join(','));
});
if (lines.length <= 1) return;
var blob = new Blob([lines.join('\n')], {type: 'text/csv;charset=utf-8'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '海外订单_' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
};
// ──────────── 包材订单管理 ────────────
var PACKAGING_PAGE_SIZE = 100;
var packagingOrderPage = 1;
var packagingSearch = '';
var packagingFilterStatus = '';
var packagingFilterSupplier = '';
var packagingFilterOrderer = '';
// ─── 冻结列切换函数 ───
window.domesticToggleFrozen = function() {
domesticFrozen = !domesticFrozen;
localStorage.setItem('domesticFrozen', domesticFrozen);
// 更新表头冻结样式
var thead = document.getElementById('domesticOrdersHeader');
if (thead) {
var scope = thead.querySelectorAll('th');
var frozen = domesticFrozen;
if (scope[2]) scope[2].style.cssText = frozen ? 'min-width:140px;position:sticky;left:150px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:140px';
if (scope[3]) scope[3].style.cssText = frozen ? 'min-width:60px;position:sticky;left:290px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:60px';
if (scope[4]) scope[4].style.cssText = frozen ? 'min-width:120px;position:sticky;left:350px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:120px';
if (scope[5]) scope[5].style.cssText = frozen ? 'min-width:200px;position:sticky;left:470px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:200px';
}
domesticLoadOrders(1);
};
window.overseasToggleFrozen = function() {
overseasFrozen = !overseasFrozen;
localStorage.setItem('overseasFrozen', overseasFrozen);
var thead = document.getElementById('overseasOrdersHeader');
if (thead) {
var scope = thead.querySelectorAll('th');
var frozen = overseasFrozen;
if (scope[2]) scope[2].style.cssText = frozen ? 'min-width:140px;position:sticky;left:150px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:140px';
if (scope[3]) scope[3].style.cssText = frozen ? 'min-width:120px;position:sticky;left:290px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:120px';
if (scope[4]) scope[4].style.cssText = frozen ? 'min-width:200px;position:sticky;left:410px;top:0;z-index:11;background:var(--bg,#f5f5f5)' : 'min-width:200px';
}
overseasLoadOrders(1);
};
function loadPackagingOrdersPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>包材订单管理</h1>
<div style="display:flex;gap:10px;margin-top:8px;flex-wrap:wrap;align-items:center">
<div class="search-box" style="max-width:300px">
<input type="text" id="packagingSearchInput" class="form-input" placeholder="搜索编码/名称/供应商..."
onkeydown="if(event.key==='Enter') packagingDoSearch()" style="padding-left:32px;padding-right:8px;height:32px;font-size:13px">
<svg style="position:absolute;left:8px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:#999" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</div>
<select id="packagingSupplierFilter" class="form-input" style="width:auto;min-width:120px;height:32px;font-size:13px" onchange="packagingApplyFilters()">
<option value="">全部供应商</option>
</select>
<select id="packagingStatusFilter" class="form-input" style="width:auto;min-width:120px;height:32px;font-size:13px" onchange="packagingApplyFilters()">
<option value="">全部状态</option>
</select>
<select id="packagingOrdererFilter" class="form-input" style="width:auto;min-width:100px;height:32px;font-size:13px" onchange="packagingApplyFilters()">
<option value="">全部下单人</option>
</select>
<button class="btn btn-sm btn-primary" onclick="packagingDoSearch()">查询</button>
<button class="btn btn-sm btn-outline" onclick="packagingResetFilters()">重置</button>
<span id="packagingTotalInfo" style="font-size:13px;color:var(--text-light);margin-left:auto"></span>
</div>
</div>
<div id="packagingContent">
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
<h3>📦 包材采购下单明细</h3>
<div>
<button class="btn btn-sm btn-primary" onclick="packagingAddRow()">+ 新增</button>
<button class="btn btn-sm btn-outline" onclick="packagingExport()">导出</button>
</div>
</div>
<div class="card-body" style="padding:0;overflow:auto">
<table class="data-table" id="packagingOrdersTable" style="font-size:12px;white-space:nowrap;min-width:2400px">
<thead><tr>
<th style="position:sticky;left:0;z-index:3;background:var(--bg,#f5f5f5);min-width:50px">#</th>
<th style="position:sticky;left:50px;z-index:3;background:var(--bg,#f5f5f5);min-width:100px">日期</th>
<th style="min-width:140px">编码</th>
<th style="min-width:250px">名称</th>
<th style="min-width:70px">单价</th>
<th style="min-width:60px">数量</th>
<th style="min-width:80px">合计</th>
<th style="min-width:100px">供应商</th>
<th style="min-width:120px">下单备注</th>
<th style="min-width:100px">仓库到货</th>
<th style="min-width:160px">进度</th>
<th style="min-width:90px">已做采购单</th>
<th style="min-width:70px">已入库</th>
<th style="min-width:80px">采购下单人</th>
<th style="min-width:120px">旺店通编号</th>
<th style="min-width:90px">录入旺店通</th>
<th style="min-width:100px">到货日期</th>
<th style="min-width:70px">状态</th>
<th style="min-width:70px">做货天数</th>
<th style="min-width:100px">包材开发</th>
<th style="min-width:80px;position:sticky;right:0;z-index:3;background:var(--bg,#f5f5f5);border-left:1px solid var(--border)">操作</th>
</tr></thead>
<tbody id="packagingOrdersTbody"><tr><td colspan="21" style="text-align:center;padding:40px;color:var(--text-light)">加载中...</td></tr></tbody>
</table>
</div>
<div id="packagingOrdersPagination" class="card-footer" style="padding:8px 16px;border-top:1px solid var(--border)"></div>
</div>
</div>
`;
packagingLoadFilters();
packagingLoadOrders(1);
}
function packagingLoadFilters() {
fetch('/api/packaging/orders/filters')
.then(function(r){return r.json()})
.then(function(res){
if (res.code !== 0) return;
var sl = document.getElementById('packagingSupplierFilter');
var ss = document.getElementById('packagingStatusFilter');
var so = document.getElementById('packagingOrdererFilter');
(res.data.suppliers || []).forEach(function(s) {
var opt = document.createElement('option');
opt.value = s; opt.textContent = s;
sl.appendChild(opt);
});
(res.data.statuses || []).forEach(function(s) {
var opt = document.createElement('option');
opt.value = s; opt.textContent = s;
ss.appendChild(opt);
});
(res.data.orderers || []).forEach(function(s) {
var opt = document.createElement('option');
opt.value = s; opt.textContent = s;
so.appendChild(opt);
});
});
}
function packagingDoSearch() {
packagingSearch = document.getElementById('packagingSearchInput').value.trim();
packagingLoadOrders(1);
}
function packagingApplyFilters() {
packagingFilterSupplier = document.getElementById('packagingSupplierFilter').value;
packagingFilterStatus = document.getElementById('packagingStatusFilter').value;
packagingFilterOrderer = document.getElementById('packagingOrdererFilter').value;
packagingLoadOrders(1);
}
function packagingResetFilters() {
document.getElementById('packagingSearchInput').value = '';
document.getElementById('packagingSupplierFilter').value = '';
document.getElementById('packagingStatusFilter').value = '';
document.getElementById('packagingOrdererFilter').value = '';
packagingSearch = ''; packagingFilterSupplier = ''; packagingFilterStatus = ''; packagingFilterOrderer = '';
packagingLoadOrders(1);
}
function packagingLoadOrders(page) {
packagingOrderPage = page;
var params = '?page=' + page + '&size=' + PACKAGING_PAGE_SIZE;
if (packagingSearch) params += '&search=' + encodeURIComponent(packagingSearch);
if (packagingFilterStatus) params += '&status=' + encodeURIComponent(packagingFilterStatus);
if (packagingFilterSupplier) params += '&supplier=' + encodeURIComponent(packagingFilterSupplier);
if (packagingFilterOrderer) params += '&orderer=' + encodeURIComponent(packagingFilterOrderer);
fetch('/api/packaging/orders/list' + params)
.then(function(r){return r.json()})
.then(function(res){
if (res.code !== 0) return;
var data = res.data;
var tbody = document.getElementById('packagingOrdersTbody');
if (document.getElementById('packagingTotalInfo'))
document.getElementById('packagingTotalInfo').textContent = '共 ' + data.total + ' 条';
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="21" style="text-align:center;padding:60px;color:var(--text-light)">暂无数据</td></tr>';
} else {
var startIdx = (page - 1) * PACKAGING_PAGE_SIZE;
var html = '';
data.rows.forEach(function(r, i) {
var rowNum = startIdx + i + 1;
html += '<tr>';
html += '<td style="position:sticky;left:0;z-index:2;background:var(--card-bg,#fff);text-align:center;color:#999">' + rowNum + '</td>';
html += '<td style="position:sticky;left:50px;z-index:2;background:var(--card-bg,#fff)">' + escHtml(r['日期'] || '') + '</td>';
html += '<td><code>' + escHtml(r['编码'] || '') + '</code></td>';
html += '<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(r['名称'] || '') + '">' + escHtml(r['名称'] || '') + '</td>';
html += '<td>' + (r['单价'] ? '¥' + parseFloat(r['单价']).toFixed(2) : '') + '</td>';
html += '<td><strong>' + (parseInt(r['数量']) || 0).toLocaleString() + '</strong></td>';
html += '<td>' + (r['合计'] ? '¥' + parseFloat(r['合计']).toLocaleString() : '') + '</td>';
html += '<td>' + escHtml(r['供应商'] || '') + '</td>';
html += '<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(r['下单备注'] || '') + '">' + escHtml(r['下单备注'] || '') + '</td>';
html += '<td>' + escHtml(r['仓库到货'] || '') + '</td>';
html += '<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(r['进度'] || '') + '">' + escHtml(r['进度'] || '') + '</td>';
html += '<td>' + escHtml(r['是否已做采购单'] || '') + '</td>';
html += '<td>' + escHtml(r['是否已做入库'] || '') + '</td>';
html += '<td>' + renderNameBadges(r['采购下单人']) + '</td>';
html += '<td><code>' + escHtml(r['旺店通编号'] || '') + '</code></td>';
html += '<td>' + escHtml(r['是否已录入旺店通'] || '') + '</td>';
html += '<td>' + escHtml(r['到货日期'] || '') + '</td>';
html += '<td><span class="badge">' + escHtml(r['状态'] || '') + '</span></td>';
html += '<td>' + escHtml(r['做货天数'] || '') + '</td>';
html += '<td>' + renderNameBadges(r['包材开发']) + '</td>';
html += '<td style="position:sticky;right:0;z-index:2;background:var(--card-bg,#fff);border-left:1px solid var(--border)">';
html += '<button class="btn btn-sm btn-outline" onclick="packagingEditRow(' + r.id +')" title="编辑" style="font-size:11px">✏️</button> ';
html += '<button class="btn btn-sm btn-outline" onclick="generateContractForOrders(\'packaging\',[' + r.id + '])" title="生成合同" style="font-size:11px;color:#52c41a;border-color:#52c41a">生成合同</button> ';
html += '<button class="btn btn-sm btn-outline-danger" onclick="packagingDeleteRow(' + r.id +')" title="删除" style="font-size:11px">🗑️</button>';
html += '</td></tr>';
});
tbody.innerHTML = html;
}
var pel = document.getElementById('packagingOrdersPagination');
if (pel) {
var totalPages = Math.ceil((data.total || 0) / PACKAGING_PAGE_SIZE);
pel.innerHTML = '<div style="display:flex;gap:5px;align-items:center;justify-content:center">';
if (page > 1) pel.innerHTML += '<button class="btn btn-sm" onclick="packagingLoadOrders(' + (page-1) + ')">上一页</button>';
pel.innerHTML += '<span style="padding:0 10px;font-size:13px">第 ' + page + '/' + totalPages + ' 页</span>';
if (page < totalPages) pel.innerHTML += '<button class="btn btn-sm" onclick="packagingLoadOrders(' + (page+1) + ')">下一页</button>';
pel.innerHTML += '</div>';
}
});
}
window.packagingEditRow = function(id) {
var newVal = prompt('输入新值(格式: 字段名=值),例如: 状态=已完结');
if (!newVal) return;
var parts = newVal.split('=');
if (parts.length < 2) { alert('格式错误'); return; }
var field = parts[0].trim();
var value = parts.slice(1).join('=').trim();
fetch('/api/packaging/orders/update', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id, field: field, value: value})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) { packagingLoadOrders(packagingOrderPage); }
else { alert(res.msg || '更新失败'); }
});
};
window.packagingDeleteRow = function(id) {
if (!confirm('确认删除?')) return;
fetch('/api/packaging/orders/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) { packagingLoadOrders(packagingOrderPage); }
else { alert(res.msg || '删除失败'); }
});
};
window.packagingAddRow = function() {
var code = prompt('请输入编码(必填):');
if (!code) return;
var name = prompt('请输入名称:');
fetch('/api/packaging/orders/add', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({编码: code, 名称: name || ''})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) { packagingLoadOrders(1); }
else { alert(res.msg || '添加失败'); }
});
};
window.packagingExport = function() {
var tbody = document.getElementById('packagingOrdersTbody');
if (!tbody) return;
var lines = [];
var headers = ['#','日期','编码','名称','单价','数量','合计','供应商','下单备注','仓库到货','进度','已做采购单','已入库','采购下单人','旺店通编号','录入旺店通','到货日期','状态','做货天数','包材开发'];
lines.push(headers.join(','));
tbody.querySelectorAll('tr').forEach(function(tr) {
var row = [];
tr.querySelectorAll('td').forEach(function(td) {
var txt = td.textContent.trim().replace(/,/g, ',').replace(/"/g, '""');
row.push('"' + txt + '"');
});
lines.push(row.join(','));
});
if (lines.length <= 1) return;
var blob = new Blob([lines.join('\n')], {type: 'text/csv;charset=utf-8'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '包材订单_' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
};
// ──────────── 成品提货表 ────────────
var FINISHED_PAGE_SIZE = 100;
var finishedPage = 1;
var finishedSearch = '';
var finishedCategory = '';
var finishedSupplier = '';
function loadFinishedProductsPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>成品提货表</h1>
<div style="display:flex;gap:10px;margin-top:8px;flex-wrap:wrap;align-items:center">
<div class="search-box" style="max-width:300px">
<input type="text" id="finishedSearchInput" class="form-input" placeholder="搜索货品编号/名称/供应商..."
onkeydown="if(event.key==='Enter') finishedDoSearch()" style="padding-left:32px;padding-right:8px;height:32px;font-size:13px">
<svg style="position:absolute;left:8px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:#999" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</div>
<select id="finishedCategoryFilter" class="form-input" style="width:auto;min-width:100px;height:32px;font-size:13px" onchange="finishedApplyFilters()">
<option value="">全部品类</option>
</select>
<select id="finishedSupplierFilter" class="form-input" style="width:auto;min-width:120px;height:32px;font-size:13px" onchange="finishedApplyFilters()">
<option value="">全部供应商</option>
</select>
<button class="btn btn-sm btn-primary" onclick="finishedDoSearch()">查询</button>
<button class="btn btn-sm btn-outline" onclick="finishedResetFilters()">重置</button>
<span id="finishedTotalInfo" style="font-size:13px;color:var(--text-light);margin-left:auto"></span>
</div>
</div>
<div id="finishedContent">
<div class="card">
<div class="card-header"><h3>📋 成品提货明细</h3></div>
<div class="card-body" style="padding:0;overflow:auto">
<table class="data-table" id="finishedOrdersTable" style="font-size:12px;white-space:nowrap;min-width:3000px">
<thead><tr>
<th style="position:sticky;left:0;z-index:3;background:var(--bg,#f5f5f5);min-width:50px">#</th>
<th style="position:sticky;left:50px;z-index:3;background:var(--bg,#f5f5f5);min-width:60px">序号</th>
<th style="min-width:60px">品类</th>
<th style="min-width:120px">货品编号</th>
<th style="min-width:250px">货品名称</th>
<th style="min-width:100px">供应商</th>
<th style="min-width:70px">可用库存</th>
<th style="min-width:60px">昨日销量</th>
<th style="min-width:65px">3天均销</th>
<th style="min-width:65px">7天均销</th>
<th style="min-width:65px">15天均销</th>
<th style="min-width:60px">支撑天数</th>
<th style="min-width:80px">采购总数量</th>
<th style="min-width:70px">生产在途</th>
<th style="min-width:70px">采购在途</th>
<th style="min-width:130px">国内下单表进度</th>
<th style="min-width:130px">海外下单表进度</th>
<th style="min-width:130px">产品进度</th>
<th style="min-width:70px">包材库存</th>
<th style="min-width:150px">采购跟单</th>
<th style="min-width:100px">采购计划</th>
<th style="min-width:200px">备注</th>
<th style="min-width:80px;position:sticky;right:0;z-index:3;background:var(--bg,#f5f5f5);border-left:1px solid var(--border)">操作</th>
</tr></thead>
<tbody id="finishedOrdersTbody"><tr><td colspan="23" style="text-align:center;padding:40px;color:var(--text-light)">加载中...</td></tr></tbody>
</table>
</div>
<div id="finishedOrdersPagination" class="card-footer" style="padding:8px 16px;border-top:1px solid var(--border)"></div>
</div>
</div>
`;
finishedLoadFilters();
finishedLoadOrders(1);
}
function finishedLoadFilters() {
fetch('/api/finished/filters')
.then(function(r){return r.json()})
.then(function(res){
if (res.code !== 0) return;
var sc = document.getElementById('finishedCategoryFilter');
var ss = document.getElementById('finishedSupplierFilter');
(res.data.categories || []).forEach(function(s) {
var opt = document.createElement('option'); opt.value = s; opt.textContent = s;
sc.appendChild(opt);
});
(res.data.suppliers || []).forEach(function(s) {
var opt = document.createElement('option'); opt.value = s; opt.textContent = s;
ss.appendChild(opt);
});
});
}
function finishedDoSearch() {
finishedSearch = document.getElementById('finishedSearchInput').value.trim();
finishedLoadOrders(1);
}
function finishedApplyFilters() {
finishedCategory = document.getElementById('finishedCategoryFilter').value;
finishedSupplier = document.getElementById('finishedSupplierFilter').value;
finishedLoadOrders(1);
}
function finishedResetFilters() {
document.getElementById('finishedSearchInput').value = '';
document.getElementById('finishedCategoryFilter').value = '';
document.getElementById('finishedSupplierFilter').value = '';
finishedSearch = ''; finishedCategory = ''; finishedSupplier = '';
finishedLoadOrders(1);
}
function finishedLoadOrders(page) {
finishedPage = page;
var params = '?page=' + page + '&size=' + FINISHED_PAGE_SIZE;
if (finishedSearch) params += '&search=' + encodeURIComponent(finishedSearch);
if (finishedCategory) params += '&category=' + encodeURIComponent(finishedCategory);
if (finishedSupplier) params += '&supplier=' + encodeURIComponent(finishedSupplier);
fetch('/api/finished/list' + params)
.then(function(r){return r.json()})
.then(function(res){
if (res.code !== 0) return;
var data = res.data;
var tbody = document.getElementById('finishedOrdersTbody');
var totalInfo = document.getElementById('finishedTotalInfo');
if (totalInfo) totalInfo.textContent = '共 ' + data.total + ' 条';
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="23" style="text-align:center;padding:60px;color:var(--text-light)">暂无数据</td></tr>';
} else {
var startIdx = (page - 1) * FINISHED_PAGE_SIZE;
var html = '';
data.rows.forEach(function(r, i) {
var rowNum = startIdx + i + 1;
html += '<tr>';
html += '<td style="position:sticky;left:0;z-index:2;background:var(--card-bg,#fff);text-align:center;color:#999">' + rowNum + '</td>';
html += '<td style="position:sticky;left:50px;z-index:2;background:var(--card-bg,#fff)">' + escHtml(r['序号'] || '') + '</td>';
html += '<td>' + escHtml(r['品类'] || '') + '</td>';
html += '<td><code>' + escHtml(r['货品编号'] || '') + '</code></td>';
html += '<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(r['货品名称'] || '') + '">' + escHtml(r['货品名称'] || '') + '</td>';
html += '<td>' + escHtml(r['供应商'] || '') + '</td>';
html += '<td><strong>' + (parseInt(r['可用库存']) || 0).toLocaleString() + '</strong></td>';
html += '<td>' + escHtml(r['昨日销量'] || '') + '</td>';
html += '<td>' + escHtml(r['3天均销量'] || '') + '</td>';
html += '<td>' + escHtml(r['7天均销量'] || '') + '</td>';
html += '<td>' + escHtml(r['15天均销量'] || '') + '</td>';
html += '<td>' + escHtml(r['支撑天数'] || '') + '</td>';
html += '<td>' + (parseInt(r['采购总数量']) || 0).toLocaleString() + '</td>';
html += '<td>' + (parseInt(r['生产在途']) || 0).toLocaleString() + '</td>';
html += '<td>' + (parseInt(r['采购在途']) || 0).toLocaleString() + '</td>';
html += '<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis">' + escHtml(r['国内下单表进度'] || '') + '</td>';
html += '<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis">' + escHtml(r['海外下单表进度'] || '') + '</td>';
html += '<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis">' + escHtml(r['产品进度'] || '') + '</td>';
html += '<td>' + (parseInt(r['包材库存']) || 0).toLocaleString() + '</td>';
html += '<td>' + renderNameBadges(r['采购跟单']) + '</td>';
html += '<td>' + renderNameBadges(r['采购计划']) + '</td>';
html += '<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(r['备注'] || '') + '">' + escHtml(r['备注'] || '') + '</td>';
html += '<td style="position:sticky;right:0;z-index:2;background:var(--card-bg,#fff);border-left:1px solid var(--border)">';
html += '<button class="btn btn-sm btn-outline-danger" onclick="finishedDeleteRow(' + r.id +')" title="删除" style="font-size:11px">🗑️</button>';
html += '</td></tr>';
});
tbody.innerHTML = html;
}
var pel = document.getElementById('finishedOrdersPagination');
if (pel) {
var totalPages = Math.ceil((data.total || 0) / FINISHED_PAGE_SIZE);
pel.innerHTML = '<div style="display:flex;gap:5px;align-items:center;justify-content:center">';
if (page > 1) pel.innerHTML += '<button class="btn btn-sm" onclick="finishedLoadOrders(' + (page-1) + ')">上一页</button>';
pel.innerHTML += '<span style="padding:0 10px;font-size:13px">第 ' + page + '/' + totalPages + ' 页</span>';
if (page < totalPages) pel.innerHTML += '<button class="btn btn-sm" onclick="finishedLoadOrders(' + (page+1) + ')">下一页</button>';
pel.innerHTML += '</div>';
}
});
}
window.finishedDeleteRow = function(id) {
if (!confirm('确认删除此记录?')) return;
fetch('/api/finished/orders/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) { finishedLoadOrders(finishedPage); }
else { alert(res.msg || '删除失败'); }
});
};
// ─── 下单备注可编辑通用函数 ───
// ─── 字段设置功能 ───
const DOMESTIC_COLUMNS = [
{field:'日期',label:'日期',defShow:true},
{field:'计划下单时间',label:'计划下单时间',defShow:true},
{field:'新旧品',label:'新旧品',defShow:true},
{field:'编码',label:'编码',defShow:true},
{field:'名称',label:'名称',defShow:true},
{field:'下单备注',label:'下单备注',defShow:true},
{field:'单价',label:'单价',defShow:true},
{field:'是否审核价格',label:'审核价格',defShow:true},
{field:'数量',label:'数量',defShow:true},
{field:'金额',label:'金额',defShow:true},
{field:'供货商',label:'供货商',defShow:true},
{field:'仓库',label:'仓库',defShow:true},
{field:'金蝶采购单',label:'金蝶采购单',defShow:true},
{field:'采购员',label:'采购员',defShow:true},
{field:'下单人',label:'下单人',defShow:true},
{field:'进度',label:'进度',defShow:true},
{field:'旺店通生产单号',label:'旺店通单号',defShow:true},
{field:'是否已录入旺店通',label:'录入旺店通',defShow:true},
{field:'采购开发',label:'采购开发',defShow:true},
{field:'实际到货日期',label:'实际到货日期',defShow:true},
{field:'实际到货数量辅助',label:'到货数量辅',defShow:true},
{field:'辅助1',label:'辅助1',defShow:true},
{field:'实际到货数量',label:'实到数量',defShow:true},
{field:'未到货数',label:'未到货数',defShow:true},
{field:'预计到货日期',label:'预计到货日期',defShow:true},
{field:'金蝶入库',label:'金蝶入库',defShow:true},
{field:'状态',label:'状态',defShow:true},
{field:'1',label:'1',defShow:false},
{field:'2',label:'2',defShow:false},
{field:'3',label:'3',defShow:false},
{field:'4',label:'4',defShow:false},
{field:'5',label:'5',defShow:false},
{field:'6',label:'6',defShow:false},
{field:'7',label:'7',defShow:false},
{field:'8',label:'8',defShow:false},
{field:'9',label:'9',defShow:false},
{field:'10',label:'10',defShow:false},
{field:'11',label:'11',defShow:false},
{field:'12',label:'12',defShow:false},
{field:'13',label:'13',defShow:false},
{field:'14',label:'14',defShow:false},
{field:'15',label:'15',defShow:false},
{field:'16',label:'16',defShow:false},
{field:'17',label:'17',defShow:false},
{field:'18',label:'18',defShow:false},
{field:'19',label:'19',defShow:false},
{field:'20',label:'20',defShow:false},
{field:'21',label:'21',defShow:false},
{field:'22',label:'22',defShow:false},
{field:'23',label:'23',defShow:false},
{field:'24',label:'24',defShow:false},
{field:'25',label:'25',defShow:false},
{field:'26',label:'26',defShow:false},
{field:'27',label:'27',defShow:false},
{field:'28',label:'28',defShow:false},
{field:'29',label:'29',defShow:false},
{field:'30',label:'30',defShow:false},
{field:'31',label:'31',defShow:false},
{field:'父记录',label:'父记录',defShow:false}
];
const OVERSEAS_COLUMNS = [
{field:'日期',label:'日期',defShow:true},
{field:'计划下单时间',label:'计划下单时间',defShow:true},
{field:'产品编码',label:'产品编码',defShow:true},
{field:'单价',label:'单价',defShow:true},
{field:'是否审核价格',label:'审核价格',defShow:true},
{field:'数量',label:'数量',defShow:true},
{field:'金额',label:'金额',defShow:true},
{field:'供应商',label:'供应商',defShow:true},
{field:'仓库',label:'仓库',defShow:true},
{field:'货品名称',label:'货品名称',defShow:true},
{field:'新旧品',label:'新旧品',defShow:true},
{field:'是否已做采购单',label:'已做采购单',defShow:true},
{field:'是否做入库',label:'已入库',defShow:true},
{field:'采购员',label:'采购员',defShow:true},
{field:'下单人',label:'下单人',defShow:true},
{field:'进度',label:'进度',defShow:true},
{field:'下单备注',label:'下单备注',defShow:true},
{field:'采购开发',label:'采购开发',defShow:true},
{field:'旺店通生产单号',label:'旺店通单号',defShow:true},
{field:'是否已录入到旺店通',label:'录入旺店通',defShow:true},
{field:'实际到货日期',label:'实际到货日期',defShow:true},
{field:'实际到货数量辅助',label:'到货数量辅',defShow:true},
{field:'辅助',label:'辅助',defShow:true},
{field:'实际到货数量',label:'实到数量',defShow:true},
{field:'预计到货日期',label:'预计到货日期',defShow:true},
{field:'未到货数',label:'未到货数',defShow:true},
{field:'状态',label:'状态',defShow:true},
{field:'1',label:'1',defShow:false},
{field:'2',label:'2',defShow:false},
{field:'3',label:'3',defShow:false},
{field:'4',label:'4',defShow:false},
{field:'5',label:'5',defShow:false},
{field:'6',label:'6',defShow:false},
{field:'7',label:'7',defShow:false},
{field:'8',label:'8',defShow:false},
{field:'9',label:'9',defShow:false},
{field:'10',label:'10',defShow:false},
{field:'11',label:'11',defShow:false},
{field:'12',label:'12',defShow:false},
{field:'13',label:'13',defShow:false},
{field:'14',label:'14',defShow:false},
{field:'15',label:'15',defShow:false},
{field:'16',label:'16',defShow:false},
{field:'17',label:'17',defShow:false},
{field:'18',label:'18',defShow:false},
{field:'19',label:'19',defShow:false},
{field:'20',label:'20',defShow:false},
{field:'21',label:'21',defShow:false},
{field:'22',label:'22',defShow:false},
{field:'23',label:'23',defShow:false},
{field:'24',label:'24',defShow:false},
{field:'25',label:'25',defShow:false},
{field:'26',label:'26',defShow:false},
{field:'27',label:'27',defShow:false},
{field:'28',label:'28',defShow:false},
{field:'29',label:'29',defShow:false},
{field:'30',label:'30',defShow:false},
{field:'31',label:'31',defShow:false}
];
function getDomesticVisible() {
var stored = localStorage.getItem('domesticColVisible');
if (stored) {
try { var m = JSON.parse(stored); return m; } catch(e) {}
}
var m = {};
DOMESTIC_COLUMNS.forEach(function(c) { m[c.field] = c.defShow; });
return m;
}
function getOverseasVisible() {
var stored = localStorage.getItem('overseasColVisible');
if (stored) {
try { var m = JSON.parse(stored); return m; } catch(e) {}
}
var m = {};
OVERSEAS_COLUMNS.forEach(function(c) { m[c.field] = c.defShow; });
return m;
}
function saveDomesticVisible(m) {
localStorage.setItem('domesticColVisible', JSON.stringify(m));
}
function saveOverseasVisible(m) {
localStorage.setItem('overseasColVisible', JSON.stringify(m));
}
function openDomesticColModal() {
openColSettingsModal('domestic', DOMESTIC_COLUMNS, getDomesticVisible(), saveDomesticVisible);
}
function openOverseasColModal() {
openColSettingsModal('overseas', OVERSEAS_COLUMNS, getOverseasVisible(), saveOverseasVisible);
}
function openColSettingsModal(type, columns, visibleMap, saveFn) {
var overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.cssText = 'display:flex;z-index:9999';
var html = '<div class="modal" style="width:650px;max-height:80vh">';
html += '<div class="modal-header"><h3>⚙ 字段设置</h3>';
html += '<span class="modal-close" onclick="this.closest(\'.modal-overlay\').remove()">&times;</span></div>';
html += '<div class="modal-body" style="max-height:60vh;overflow-y:auto;padding:12px 20px">';
html += '<div style="margin-bottom:10px">';
html += '<label><input type="checkbox" id="colSelectAll" onchange="toggleColAll(this.checked)" checked> 全选</label>';
html += ' <button class="btn btn-sm btn-outline" onclick="resetColDefaults(\'' + type + '\')">恢复默认</button>';
html += '</div>';
html += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px">';
columns.forEach(function(c) {
var checked = visibleMap[c.field] ? 'checked' : '';
html += '<label style="font-size:13px;padding:4px 6px;border:1px solid #f0f0f0;border-radius:4px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">';
html += '<input type="checkbox" class="col-checkbox" value="' + c.field + '" ' + checked + ' onchange="updateColSelectAll()"> ';
html += c.label;
html += '</label>';
});
html += '</div></div>';
html += '<div class="modal-footer">';
html += '<button class="btn" onclick="this.closest(\'.modal-overlay\').remove()">取消</button> ';
html += '<button class="btn btn-primary" onclick="applyColSettings(\'' + type + '\',\'' + '\')">应用</button>';
html += '</div></div>';
overlay.innerHTML = html;
document.body.appendChild(overlay);
}
window.toggleColAll = function(checked) {
document.querySelectorAll('.col-checkbox').forEach(function(cb) { cb.checked = checked; });
};
window.updateColSelectAll = function() {
var all = document.querySelectorAll('.col-checkbox');
var checked = document.querySelectorAll('.col-checkbox:checked');
var selectAll = document.getElementById('colSelectAll');
if (selectAll) selectAll.checked = (all.length === checked.length);
};
window.resetColDefaults = function(type) {
var cols = type === 'domestic' ? DOMESTIC_COLUMNS : OVERSEAS_COLUMNS;
document.querySelectorAll('.col-checkbox').forEach(function(cb) {
var col = cols.find(function(c) { return c.field === cb.value; });
cb.checked = col ? col.defShow : false;
});
updateColSelectAll();
};
window.applyColSettings = function(type) {
var cols = type === 'domestic' ? DOMESTIC_COLUMNS : OVERSEAS_COLUMNS;
var visible = {};
document.querySelectorAll('.col-checkbox').forEach(function(cb) {
visible[cb.value] = cb.checked;
});
if (type === 'domestic') {
saveDomesticVisible(visible);
domesticLoadOrders(1);
} else {
saveOverseasVisible(visible);
overseasLoadOrders(1);
}
document.querySelector('.modal-overlay').remove();
};
window.editRemark = function(td, id, field, apiEndpoint) {
var textSpan = td.querySelector('.remark-text');
var current = textSpan ? textSpan.textContent.trim() : td.textContent.trim();
var input = document.createElement('input');
input.type = 'text';
input.className = 'remark-input';
input.value = current;
input.style.cssText = 'width:100%;border:1px solid #1890ff;padding:2px 6px;font-size:12px;border-radius:3px;box-sizing:border-box';
td.innerHTML = '';
td.appendChild(input);
input.focus();
function revert() {
if (td.querySelector('input')) {
td.innerHTML = '<span class="remark-text">' + escHtml(current) + '</span> <span class="remark-edit-btn" onclick="event.stopPropagation();editRemark(this.parentNode,' + id + ',\'' + field + '\',\'' + apiEndpoint + '\')" style="cursor:pointer;color:#1890ff;font-size:12px">✏️</span>';
}
}
function doSave() {
var newVal = input.value.trim();
if (newVal === current) { revert(); return; }
if (confirm('是否保存修改?')) {
fetch(apiEndpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id, field: field, value: newVal})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) {
td.innerHTML = '<span class="remark-text">' + escHtml(newVal) + '</span> <span class="remark-edit-btn" onclick="event.stopPropagation();editRemark(this.parentNode,' + id + ',\'' + field + '\',\'' + apiEndpoint + '\')" style="cursor:pointer;color:#1890ff;font-size:12px">✏️</span>';
} else {
alert('保存失败: ' + (res.msg || ''));
revert();
}
})
.catch(function(){
alert('网络错误');
revert();
});
} else {
revert();
}
}
input.onkeydown = function(e) {
if (e.key === 'Enter') { e.preventDefault(); doSave(); }
else if (e.key === 'Escape') { revert(); }
};
input.onblur = function() {
setTimeout(function() {
if (td.querySelector('input')) revert();
}, 250);
};
};
// ─── 单价保存函数 ───
window.priceSave = function(input) {
var oldVal = input.defaultValue;
var newVal = input.value.trim();
if (newVal === oldVal) return;
var id = input.getAttribute('data-pid');
var field = input.getAttribute('data-pfield');
var api = input.getAttribute('data-papi');
if (confirm('是否保存修改?')) {
fetch(api, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: parseInt(id), field: field, value: newVal || '0'})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) {
input.defaultValue = newVal;
if (window.domesticLoadOrders) domesticLoadOrders(domesticOrderPage || 1);
else if (window.overseasLoadOrders) overseasLoadOrders(overseasOrderPage || 1);
} else {
alert('保存失败: ' + (res.msg || ''));
input.value = oldVal;
}
})
.catch(function(){ alert('网络错误'); input.value = oldVal; });
} else {
input.value = oldVal;
}
};
// 表格事件委托:单价输入框
if (!window._priceDelegated) {
window._priceDelegated = true;
document.addEventListener('focusin', function(e) {
if (e.target.classList.contains('price-inline')) e.target.style.borderColor = '#1890ff';
});
document.addEventListener('focusout', function(e) {
if (e.target.classList.contains('price-inline')) {
e.target.style.borderColor = '#d9d9d9';
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && e.target.classList.contains('price-inline')) {
e.preventDefault();
priceSave(e.target);
}
});
}
// ─── 日期编辑函数 ───
window.editDateField = function(td, id, field, apiEndpoint) {
var textSpan = td.querySelector('.date-text');
var current = textSpan ? textSpan.textContent.trim() : td.textContent.trim();
var dateOnly = current.split(' ')[0];
var input = document.createElement('input');
input.type = 'date';
input.className = 'date-input';
input.value = dateOnly || '';
input.style.cssText = 'width:100%;border:1px solid #1890ff;padding:2px 4px;font-size:12px;border-radius:3px;box-sizing:border-box';
td.innerHTML = '';
td.appendChild(input);
input.focus();
// 主动弹出日期选择器(标记pickering防止blur误杀)
input._picking = true;
if (input.showPicker) input.showPicker();
setTimeout(function() { input._picking = false; }, 500);
// 点击外部关闭编辑器
function outsideClick(e) {
if (!td.contains(e.target)) {
document.removeEventListener('click', outsideClick, true);
if (!saved) { saved = true; restoreView(current); }
}
}
setTimeout(function() { document.addEventListener('click', outsideClick, true); }, 100);
function makeIconTd(val) {
var v = val || '';
return '<span class="date-text">' + escHtml(v) + '</span> <span class="date-edit-btn" data-did="' + id + '" data-dfield="' + field + '" data-dapi="' + apiEndpoint + '" style="cursor:pointer;color:#1890ff;font-size:12px">📅</span>';
}
function restoreView(val) {
td.innerHTML = makeIconTd(val);
td.querySelector('.date-edit-btn').onclick = function() {
editDateField(td, parseInt(this.getAttribute('data-did')), this.getAttribute('data-dfield'), this.getAttribute('data-dapi'));
};
}
var saved = false;
function doSave() {
if (saved) return;
saved = true;
var newVal = input.value;
var isClear = !newVal && !!dateOnly;
// 无变化 → 直接退出
if (newVal === dateOnly || (!newVal && !dateOnly)) {
restoreView(current);
return;
}
// 清空日期 → 直接保存不弹框
if (isClear) {
fetch(apiEndpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id, field: field, value: ''})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) {
if (window.domesticLoadOrders) domesticLoadOrders(domesticOrderPage || 1);
else if (window.overseasLoadOrders) overseasLoadOrders(overseasOrderPage || 1);
} else {
alert('保存失败: ' + (res.msg || ''));
restoreView(current);
}
})
.catch(function(){ alert('网络错误'); restoreView(current); });
return;
}
// 修改日期 → 弹确认
if (confirm('是否保存修改?')) {
fetch(apiEndpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id, field: field, value: newVal})
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code === 0) {
if (window.domesticLoadOrders) domesticLoadOrders(domesticOrderPage || 1);
else if (window.overseasLoadOrders) overseasLoadOrders(overseasOrderPage || 1);
} else {
alert('保存失败: ' + (res.msg || ''));
restoreView(current);
}
})
.catch(function(){ alert('网络错误'); restoreView(current); });
} else {
restoreView(current);
}
}
input.onchange = function() { doSave(); };
input.onblur = function() {
if (saved || input._picking) return;
saved = true;
restoreView(current);
};
};
// ──────────── 订单管理(原采购订单) ────────────
function loadOrdersPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>订单管理</h1>
<p class="page-desc">已生成的采购订单明细</p>
</div>
<div id="ordersContent">
<div class="card">
<div class="card-header"><h3>📋 采购订单明细</h3></div>
<div class="card-body" style="padding:0;overflow-x:auto">
<table class="data-table" id="ordersTable">
<thead><tr>
<th>序号</th><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 id="ordersTbody"><tr><td colspan="12" style="text-align:center;padding:40px;color:var(--text-light)">加载中...</td></tr></tbody>
</table>
</div>
<div id="ordersPagination" class="card-footer" style="padding:8px 16px;border-top:1px solid var(--border)"></div>
</div>
</div>
<!-- 订单详情弹窗 -->
<div class="modal-overlay" id="orderDetailModal" style="display:none">
<div class="modal" style="width:900px">
<div class="modal-header">
<h3 id="orderDetailTitle">订单详情</h3>
<span class="modal-close" onclick="document.getElementById('orderDetailModal').style.display='none'">&times;</span>
</div>
<div class="modal-body" style="max-height:70vh;overflow-y:auto;padding:16px 20px">
<div id="orderDetailContent"></div>
</div>
<div class="modal-footer">
<button class="btn" onclick="document.getElementById('orderDetailModal').style.display='none'">关闭</button>
</div>
</div>
</div>
<!-- 订单编辑弹窗 -->
<div class="modal-overlay" id="orderEditModal" style="display:none">
<div class="modal" style="width:500px">
<div class="modal-header">
<h3>编辑订单明细</h3>
<span class="modal-close" onclick="document.getElementById('orderEditModal').style.display='none'">&times;</span>
</div>
<div class="modal-body">
<input type="hidden" id="editItemId">
<div class="form-group">
<label>货品编号</label>
<input type="text" id="editItemCode" class="form-input" readonly>
</div>
<div class="form-group">
<label>货品名称</label>
<input type="text" id="editItemName" class="form-input" readonly>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>单价</label>
<input type="number" id="editUnitPrice" class="form-input" step="0.01">
</div>
<div class="form-group" style="flex:1">
<label>数量</label>
<input type="number" id="editOrderQty" class="form-input">
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="document.getElementById('orderEditModal').style.display='none'">取消</button>
<button class="btn btn-primary" onclick="saveOrderItem()">保存</button>
</div>
</div>
</div>
`;
loadOrders(1);
}
function loadOrders(page) {
ordersCurrentPage = page;
fetch('/api/purchase/orders/list?page=' + page + '&size=' + ORDERS_PAGE_SIZE)
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
var data = res.data;
var tbody = document.getElementById('ordersTbody');
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="12" style="text-align:center;padding:60px;color:var(--text-light)">暂无采购订单明细</td></tr>';
} else {
var startIdx = (page - 1) * ORDERS_PAGE_SIZE;
var statusMap = {'pending':'🟡 待确认','confirmed':'🟢 已确认'};
tbody.innerHTML = data.rows.map(function(o, i) {
var amount = (parseFloat(o.order_qty) || 0) * (parseFloat(o.unit_price) || 0);
var statLabel = statusMap[o.status] || o.status || '🟡 待确认';
var isPending = (o.status || 'pending') === 'pending';
return '<tr>' +
'<td>' + (startIdx + i + 1) + '</td>' +
'<td>' + escHtml(o.order_time || '-') + '</td>' +
'<td><code>' + escHtml(o.order_no) + '</code></td>' +
'<td><code>' + escHtml(o.product_code) + '</code></td>' +
'<td>' + escHtml(o.product_name || '-') + '</td>' +
'<td>¥' + (parseFloat(o.unit_price) || 0).toFixed(2) + '</td>' +
'<td><strong>' + (parseInt(o.order_qty) || 0).toLocaleString() + '</strong></td>' +
'<td><strong>¥' + (parseFloat(o.amount || amount) || 0).toLocaleString() + '</strong></td>' +
'<td>' + escHtml(o.supplier_name) + '</td>' +
'<td>' + (o.lead_time || '-') + '天</td>' +
'<td>' + statLabel + '</td>' +
'<td style="white-space:nowrap">' +
'<button class="btn btn-sm btn-outline" onclick="viewOrder(' + o.order_id + ')" title="详情">详情</button> ' +
'<button class="btn btn-sm btn-outline" onclick="editOrderItem(' + o.item_id + ',\'" + escHtml(o.product_code) + "\',\'" + escHtml(o.product_name) + "\',' + (parseFloat(o.unit_price) || 0) + ',' + (parseInt(o.order_qty) || 0) + ')" title="编辑">编辑</button> ' +
(isPending ? '<button class="btn btn-sm btn-outline" onclick="submitOrder(' + o.order_id + ')" title="提交">提交</button> ' : '') +
'<button class="btn btn-sm btn-outline-danger" onclick="deleteOrder(' + o.order_id + ')" title="删除">删除</button>' +
'</td>' +
'</tr>';
}).join('');
}
var pel = document.getElementById('ordersPagination');
if (pel) {
renderPaginationWithSize(pel, page, data.total || 0, ORDERS_PAGE_SIZE, 'loadOrders', 'ORDERS_PAGE_SIZE');
}
});
}
window.viewOrder = function(orderId) {
document.getElementById('orderDetailModal').style.display = 'flex';
document.getElementById('orderDetailTitle').textContent = '订单详情';
var content = document.getElementById('orderDetailContent');
content.innerHTML = '<div class="page-loading"><div class="loading-spinner"></div></div>';
fetch('/api/purchase/orders/detail?id=' + orderId)
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) { content.innerHTML = '<div style="text-align:center;padding:40px;color:red">' + (res.msg || '加载失败') + '</div>'; return; }
var order = res.data.order;
var items = res.data.items || [];
var statusMap = {'pending':'🟡 待确认','confirmed':'🟢 已确认'};
var html = '<div class="card" style="margin-bottom:15px"><div class="card-body" style="padding:12px 16px">';
html += '<div style="display:flex;flex-wrap:wrap;gap:20px;font-size:14px">';
html += '<span><strong>订单号:</strong><code>' + escHtml(order.order_no) + '</code></span>';
html += '<span><strong>供应商:</strong>' + escHtml(order.supplier_name) + '</span>';
html += '<span><strong>总数量:</strong>' + (order.total_qty || 0).toLocaleString() + '</span>';
html += '<span><strong>总金额:</strong>¥' + (parseFloat(order.total_amount) || 0).toLocaleString() + '</span>';
html += '<span><strong>状态:</strong>' + (statusMap[order.status] || order.status) + '</span>';
html += '<span><strong>创建人:</strong>' + escHtml(order.created_by || '-') + '</span>';
html += '<span><strong>创建时间:</strong>' + (order.created_at || '-') + '</span>';
html += '</div></div></div>';
if (items.length > 0) {
html += '<table class="data-table" style="font-size:13px"><thead><tr>';
html += '<th>货品编号</th><th>货品名称</th><th>规格</th><th>建议下单</th><th>下单数量</th><th>单价</th><th>金额</th><th>货期</th><th class="col-action" style="width:120px;min-width:120px;position:sticky;right:0;background:var(--bg,#fff);z-index:3;border-left:1px solid var(--border)">操作</th></tr></thead><tbody>';
items.forEach(function(item) {
html += '<tr><td><code>' + escHtml(item.product_code) + '</code></td><td>' + escHtml(item.product_name) + '</td><td>' + escHtml(item.specification || '-') + '</td><td>' + (item.suggested_qty || 0) + '</td><td><strong>' + (item.order_qty || 0).toLocaleString() + '</strong></td><td>¥' + parseFloat(item.unit_price || 0).toFixed(2) + '</td><td>¥' + (parseFloat(item.amount) || 0).toLocaleString() + '</td><td>' + (item.lead_time || '-') + '天</td></tr>';
});
html += '</tbody></table>';
}
content.innerHTML = html;
});
};
window.editOrderItem = function(itemId, code, name, price, qty) {
document.getElementById('editItemId').value = itemId;
document.getElementById('editItemCode').value = code;
document.getElementById('editItemName').value = name;
document.getElementById('editUnitPrice').value = price;
document.getElementById('editOrderQty').value = qty;
document.getElementById('orderEditModal').style.display = 'flex';
};
window.saveOrderItem = function() {
var itemId = document.getElementById('editItemId').value;
var price = parseFloat(document.getElementById('editUnitPrice').value) || 0;
var qty = parseInt(document.getElementById('editOrderQty').value) || 0;
fetch('/api/purchase/orders/update_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({item_id: itemId, order_qty: qty, unit_price: price})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
document.getElementById('orderEditModal').style.display = 'none';
loadOrders(ordersCurrentPage);
} else {
alert(res.msg || '保存失败');
}
});
};
window.submitOrder = function(orderId) {
if (!confirm('确认提交此订单?提交后不可撤回。')) return;
fetch('/api/purchase/orders/update_status', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: orderId, status: 'confirmed'})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
loadOrders(ordersCurrentPage);
} else {
alert(res.msg || '操作失败');
}
});
};
window.deleteOrder = function(orderId) {
if (!confirm('确定删除此订单?删除后不可恢复。')) return;
fetch('/api/purchase/orders/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: orderId})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
loadOrders(ordersCurrentPage);
} else {
alert(res.msg || '操作失败');
}
});
};
// ─── 物料清单管理 (树形折叠视图,以货品编号分页) ───
var BOM_PAGE_SIZE = 20;
var BOM_PAGE_SIZE_OPTIONS = [10, 20, 50];
var bomCurrentPage = 1;
var bomExpanded = {}; // { "productCode": true/false }
function loadBomPage(container) {
container.innerHTML = `
<div class="page-header">
<h1>物料清单管理</h1>
<p class="page-desc">产品物料清单 — 以货品编号分页,点击产品行展开物料明细</p>
</div>
<div id="bomContent">
<div class="card">
<div class="card-header">
<h3>📋 物料清单</h3>
<div style="display:flex;gap:6px;align-items:center;flex:1;justify-content:flex-end">
<input type="text" id="bomSupplierFilter" class="form-input" style="width:130px" placeholder="🏢 供应商筛选" autocomplete="off" onkeydown="bomSearchKeydown(event)">
<select id="bomMaintFilter" class="form-input" style="width:80px" onchange="bomSearch()">
<option value="">全部</option>
<option value="有BOM">有BOM</option>
<option value="无BOM">无BOM</option>
</select>
<input type="text" id="bomSearchInput" class="form-input" style="width:180px" placeholder="🔍 搜索编号/名称" autocomplete="off" onkeydown="bomSearchKeydown(event)">
<button class="btn btn-sm btn-primary" onclick="bomSearch()" style="padding:3px 10px">🔍 搜索</button>
<button class="btn btn-sm" onclick="bomReset()" style="padding:3px 10px;color:#888;border-color:#d9d9d9">↺ 重置</button>
<button class="btn btn-sm" style="background:#52c41a;color:#fff;border-color:#52c41a;padding:3px 10px" onclick="openBomAddModal()">+ 新增</button>
<button class="btn btn-sm" style="background:#fa8c16;color:#fff;border-color:#fa8c16;padding:3px 10px" onclick="openBomImportModal()">📥 导入</button>
<button class="btn btn-sm" style="background:#722ed1;color:#fff;border-color:#722ed1;padding:3px 10px" onclick="exportBom()">📤 导出</button>
</div>
</div>
<div class="card-body" style="padding:0;overflow-x:auto">
<table class="data-table" id="bomTable">
<thead style="position:sticky;top:0;z-index:4;background:var(--bg,#fff)"><tr>
<th style="width:40px"></th>
<th>货品/物料编号</th>
<th>名称</th>
<th style="width:50px;text-align:center">用量</th>
<th>类型</th>
<th>成本价</th>
<th>供应商</th>
<th>可用库存</th>
<th>生产在途</th>
<th>采购在途</th>
<th style="width:50px;text-align:center">维护</th>
<th class="col-action" style="width:120px;min-width:120px;position:sticky;right:0;background:var(--bg,#fff);z-index:3;border-left:1px solid var(--border)">操作</th>
</tr></thead>
<tbody id="bomTbody"><tr><td colspan="13" style="text-align:center;padding:40px;color:var(--text-light)">加载中...</td></tr></tbody>
</table>
</div>
<div id="bomPagination" class="card-footer" style="padding:8px 16px;border-top:1px solid var(--border);text-align:center"></div>
</div>
</div>
`;
loadBom(1);
}
function loadBom(page) {
bomCurrentPage = page;
var search = document.getElementById('bomSearchInput')?.value || '';
var maintFilter = document.getElementById('bomMaintFilter')?.value || '';
var supplierFilter = document.getElementById('bomSupplierFilter')?.value || '';
var url = '/api/bom/list?page=' + page + '&size=' + BOM_PAGE_SIZE;
if (search) url += '&search=' + encodeURIComponent(search);
if (maintFilter) url += '&maint_filter=' + encodeURIComponent(maintFilter);
if (supplierFilter) url += '&supplier=' + encodeURIComponent(supplierFilter);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
var data = res.data;
var tbody = document.getElementById('bomTbody');
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="13" style="text-align:center;padding:60px;color:var(--text-light)">暂无数据,请先导入BOM文件</td></tr>';
} else {
// Group by product_code (already batched by product)
var groups = {};
var groupOrder = [];
data.rows.forEach(function(r) {
var key = r.product_code;
if (!groups[key]) {
groups[key] = { product_code: r.product_code, product_name: r.product_name, product_cost_price: r.product_cost_price, product_supplier: r.product_supplier, materials: [] };
groupOrder.push(key);
}
groups[key].materials.push(r);
});
var html = '';
groupOrder.forEach(function(key) {
var g = groups[key];
var isExpanded = bomExpanded[key] === true;
// Product header row
var productCost = g.product_cost_price ? '¥' + parseFloat(g.product_cost_price).toFixed(2) : '-';
var productSupplier = g.product_supplier || '-';
var foldIcon = isExpanded ? '▼' : '▶';
// 成品库存(从第一行取,所有行同产品数据一致)
var prodAvail = g.materials[0] ? (parseInt(g.materials[0].product_available_qty) || 0) : 0;
var prodProdTr = g.materials[0] ? (parseInt(g.materials[0].product_production_in_transit) || 0) : 0;
var prodPurchTr = g.materials[0] ? (parseInt(g.materials[0].product_purchase_in_transit) || 0) : 0;
var prodInvClass = prodAvail > 0 ? '' : ' style="color:#ff4d4f;font-weight:600"';
// 判断是否有实际包材
var hasRealMats = g.materials.some(function(r) { return r.material_code && r.material_code.trim(); });
var maintLabel = hasRealMats ? '<span style="color:#52c41a;font-weight:600">有BOM</span>' : '<span style="color:#ff4d4f;font-weight:600">无BOM</span>';
html += '<tr class="bom-product-row" style="cursor:pointer;background:var(--bg-hover);font-weight:600">' +
'<td style="text-align:center;cursor:pointer" onclick="toggleBomMaterial(\'' + escJs(key) + '\')">' + foldIcon + '</td>' +
'<td><code>' + escHtml(g.product_code) + '</code></td>' +
'<td>' + escHtml(g.product_name) + '</td>' +
'<td style="text-align:center">-</td>' +
'<td><span class="tag tag-blue">成品</span></td>' +
'<td>' + productCost + '</td>' +
'<td>' + escHtml(productSupplier) + '</td>' +
'<td' + prodInvClass + '>' + prodAvail.toLocaleString() + '</td>' +
'<td>' + prodProdTr.toLocaleString() + '</td>' +
'<td>' + prodPurchTr.toLocaleString() + '</td>' +
'<td style="text-align:center">' + maintLabel + '</td>' +
'<td class="col-action" style="position:sticky;right:0;z-index:2;background:var(--bg,#fff);border-left:1px solid var(--border);white-space:nowrap">' +
'<button class="btn btn-sm btn-outline" onclick="showBomProductDetail(\'' + escJs(key) + '\')" title="展开详情">详情</button> ' +
'<button class="btn btn-sm btn-outline" onclick="showBomEdit(\'' + escJs(key) + '\')" title="编辑BOM">编辑</button> ' +
'<button class="btn btn-sm btn-outline-danger" onclick="deleteBomProduct(\'' + escJs(key) + '\')" style="color:#f5222d;border-color:#f5222d">删除</button>' +
'</td>' +
'</tr>';
// Material detail rows (hidden unless expanded)
if (isExpanded) {
// 过滤掉占位空物料行
var realMats = g.materials.filter(function(r) { return r.material_code && r.material_code.trim(); });
if (realMats.length === 0) {
html += '<tr class="bom-material-row" style="background:#fafbfc">' +
'<td colspan="11" style="text-align:center;padding:20px;color:#bbb;font-size:13px">暂无包材数据,请点击编辑维护</td></tr>';
} else {
realMats.forEach(function(r) {
var qty = parseInt(r.quantity) + '';
var matCost = r.material_cost_price ? '¥' + parseFloat(r.material_cost_price).toFixed(2) : '-';
var matSupplier = r.material_supplier || '-';
var avail = r.mat_available_qty || 0;
var prodTr = r.mat_prod_transit || 0;
var purchTr = r.mat_purch_transit || 0;
html += '<tr class="bom-material-row" style="background:#fafbfc">' +
'<td></td>' +
'<td><code>' + escHtml(r.material_code) + '</code></td>' +
'<td>' + escHtml(r.material_name) + '</td>' +
'<td style="text-align:center">' + qty + '</td>' +
'<td><span class="tag tag-orange">包材</span></td>' +
'<td>' + matCost + '</td>' +
'<td>' + escHtml(matSupplier) + '</td>' +
'<td>' + (avail || 0) + '</td>' +
'<td>' + (prodTr || 0) + '</td>' +
'<td>' + (purchTr || 0) + '</td>' +
'<td></td>' +
'</tr>';
});
}
}
});
tbody.innerHTML = html;
}
var pel = document.getElementById('bomPagination');
if (pel) {
renderPaginationWithSize(pel, page, data.total || 0, BOM_PAGE_SIZE, 'loadBom', 'BOM_PAGE_SIZE');
}
});
}
window.toggleBomMaterial = function(productCode) {
bomExpanded[productCode] = !bomExpanded[productCode];
loadBom(bomCurrentPage);
};
// 绑定回车搜索
window.bomSearchKeydown = function(evt) {
if (evt.key === 'Enter') bomSearch();
};
window.bomSearch = function() { bomCurrentPage = 1; loadBom(1); };
// 重置所有筛选条件
window.bomReset = function() {
var si = document.getElementById('bomSearchInput');
var sf = document.getElementById('bomSupplierFilter');
var mf = document.getElementById('bomMaintFilter');
if (si) si.value = '';
if (sf) sf.value = '';
if (mf) mf.value = '';
bomCurrentPage = 1;
loadBom(1);
};
window.bomMaintFilterChange = function() { bomCurrentPage = 1; loadBom(1); };
// ─── 物料清单编辑弹窗 ───
window.showBomEdit = function(productCode) {
if (!productCode) return;
fetch('/api/product/detail_with_bom?code=' + encodeURIComponent(productCode))
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) { alert(res.msg || '获取BOM数据失败'); return; }
var materials = (res.data && res.data.bom_materials) || [];
var old = document.getElementById('bomEditModal');
if (old) old.remove();
var overlay = document.createElement('div');
overlay.id = 'bomEditModal';
overlay.className = 'modal-overlay';
overlay.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:99999;align-items:center;justify-content:center';
overlay.onclick = function(e) { if (e.target === overlay) {} };
// Build rows
var bodyHtml = '';
if (materials.length === 0) {
bodyHtml = window._bomEditRowHtml(1);
} else {
materials.forEach(function(m, i) {
bodyHtml += window._bomEditRowHtml(i + 1, m);
});
}
var productName = (res.data && res.data.product && res.data.product.product_name) || productCode;
overlay.innerHTML = [
'<div class="modal" style="width:95vw;max-width:900px">',
'<div class="modal-header">',
'<h3>✏️ 编辑物料清单 — <strong>' + escHtml(productName) + '</strong></h3>',
'<span class="modal-close" onclick="document.getElementById(\'bomEditModal\').remove()" style="margin-left:auto;font-size:22px;cursor:pointer;color:#999">✕</span>',
'</div>',
'<div class="modal-body" style="padding:10px 16px;overflow:auto;max-height:65vh">',
'<table class="data-table" style="width:100%;font-size:12px">',
'<thead><tr><th style="width:30px">#</th><th>物料编号</th><th>物料名称</th><th style="width:60px;text-align:center">用量</th><th style="width:80px;text-align:center">成本价</th><th>供应商</th><th style="width:30px"></th></tr></thead>',
'<tbody id="bomEditTbody">' + bodyHtml + '</tbody>',
'</table>',
'<div style="margin-top:6px"><button class="btn btn-sm" onclick="addBomEditRow()">+ 添加一行</button></div>',
'</div>',
'<div class="modal-footer" style="display:flex;justify-content:space-between">',
'<button class="btn" onclick="document.getElementById(\'bomEditModal\').remove()">取消</button>',
'<button class="btn btn-primary" onclick="submitBomEdit(\'' + escJs(productCode) + '\')">💾 保存</button>',
'</div>',
'</div>'
].join('');
document.body.appendChild(overlay);
window._bindBomSupEvents(document.querySelectorAll('#bomEditTbody .bom-sup-name'));
});
};
window._bomEditRowHtml = function(idx, data) {
data = data || {};
var code = escHtml(String(data.material_code || ''));
var name = escHtml(String(data.material_name || ''));
var qty = data.bom_qty || 1;
var cost = data.material_cost || data.material_cost_price || 0;
var supName = escHtml(String(data.material_supplier || ''));
var html = '<tr class="bom-bind-row">';
html += '<td>' + idx + '</td>';
html += '<td><input class="form-input bom-mat-code" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px" value="' + code + '" placeholder="必填" autocomplete="off"></td>';
html += '<td><input class="form-input bom-mat-name" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px" value="' + name + '" placeholder="必填" autocomplete="off"></td>';
html += '<td><input class="form-input bom-mat-qty" type="number" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px;text-align:center" value="' + qty + '" min="0.01" step="0.01" autocomplete="off"></td>';
html += '<td><input class="form-input bom-mat-cost" type="number" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px;text-align:center" value="' + cost + '" min="0" step="0.01" autocomplete="off"></td>';
html += '<td style="position:relative"><input class="form-input bom-sup-name" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px" value="' + supName + '" placeholder="输入关键词" autocomplete="off">';
html += '<div class="bom-sup-dd" style="position:absolute;top:100%;left:0;right:0;z-index:100;background:#fff;border:1px solid #d9d9d9;border-radius:4px;max-height:160px;overflow-y:auto;display:none;box-shadow:0 4px 12px rgba(0,0,0,0.12)"></div></td>';
html += '<td style="width:30px;text-align:center"><span style="cursor:pointer;color:#999;font-size:14px" onclick="removeBomBindRow(this)">✕</span></td>';
html += '</tr>';
return html;
};
window.addBomEditRow = function() {
var tbody = document.getElementById('bomEditTbody');
if (!tbody) return;
var idx = tbody.querySelectorAll('.bom-bind-row').length + 1;
tbody.insertAdjacentHTML('beforeend', window._bomEditRowHtml(idx));
window._bindBomSupEvents(tbody.querySelectorAll('.bom-bind-row:last-child .bom-sup-name'));
};
window.submitBomEdit = function(productCode) {
var tbody = document.getElementById('bomEditTbody');
if (!tbody) return;
var materials = [];
[].forEach.call(tbody.querySelectorAll('.bom-bind-row'), function(row) {
var code = (row.querySelector('.bom-mat-code')?.value || '').trim();
var name = (row.querySelector('.bom-mat-name')?.value || '').trim();
var supName = (row.querySelector('.bom-sup-name')?.value || '').trim();
var qty = parseFloat(row.querySelector('.bom-mat-qty')?.value) || 1;
var cost = parseFloat(row.querySelector('.bom-mat-cost')?.value) || 0;
if (code) { materials.push({material_code: code, material_name: name, quantity: qty, cost_price: cost, supplier_name: supName}); }
});
if (materials.length === 0) { alert('请填写至少一行物料信息'); return; }
var btn = document.querySelector('.btn-primary[onclick*="submitBomEdit"]');
if (btn) { btn.disabled = true; btn.textContent = '保存中...'; }
// 先删除旧的,再新增
fetch('/api/bom/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode})
})
.then(function(r) { return r.json(); })
.then(function(delRes) {
if (delRes.code !== 0) { throw new Error(delRes.msg || '删除旧数据失败'); }
return fetch('/api/bom/create_with_materials', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode, materials: materials})
});
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (btn) { btn.disabled = false; btn.textContent = '💾 保存'; }
if (res.code === 0) {
alert('✅ ' + res.msg);
document.getElementById('bomEditModal').remove();
loadBom(bomCurrentPage);
} else {
alert('❌ ' + (res.msg || '保存失败'));
}
})
.catch(function(err) {
if (btn) { btn.disabled = false; btn.textContent = '💾 保存'; }
alert('❌ ' + (err.message || '操作失败'));
});
};
// ─── 删除成品BOM(含所有物料行) ───
window.deleteBomProduct = function(productCode) {
if (!productCode) return;
if (!confirm('确认删除 [' + productCode + '] 的物料清单数据吗?此操作不可恢复!')) return;
fetch('/api/bom/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
alert('✅ ' + (res.msg || '删除成功'));
loadBom(bomCurrentPage);
} else {
alert('❌ ' + (res.msg || '删除失败'));
}
})
.catch(function() { alert('❌ 网络错误'); });
};
// ─── BOM新增弹窗 ───
window.openBomAddModal = function() {
var old = document.getElementById('bomAddModal');
if (old) old.remove();
var overlay = document.createElement('div');
overlay.id = 'bomAddModal';
overlay.className = 'modal-overlay';
overlay.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:99999;align-items:center;justify-content:center';
overlay.onclick = function(e) { if (e.target === overlay) {} };
overlay.innerHTML = [
'<div class="modal" style="width:95vw;max-width:900px">',
'<div class="modal-header">',
'<h3>✏️ 新建物料清单</h3>',
'<span style="font-size:13px;color:#888;font-weight:normal;margin-left:10px">成品 + 包材一次性添加</span>',
'<span class="modal-close" onclick="closeBomAddModal()" style="margin-left:auto;font-size:22px;cursor:pointer;color:#999">✕</span>',
'</div>',
'<div class="modal-body" style="padding:12px 16px;max-height:70vh;overflow-y:auto">',
'<!-- 成品选择区 -->',
'<div class="pd-card" style="margin-bottom:10px">',
'<div class="pd-card-header"><span>🏭 成品信息</span></div>',
'<div class="pd-card-body" style="padding:10px 14px">',
'<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">',
'<div style="flex:1;min-width:150px"><label style="font-size:12px;color:#888">成品编号 *</label>',
'<input id="bomNewProdCode" class="form-input" style="width:100%;box-sizing:border-box" placeholder="必填" autocomplete="off"></div>',
'<div style="flex:1;min-width:150px"><label style="font-size:12px;color:#888">成品名称</label>',
'<input id="bomNewProdName" class="form-input" style="width:100%;box-sizing:border-box" placeholder="必填" autocomplete="off"></div>',
'<div style="flex:1;min-width:150px"><label style="font-size:12px;color:#888">供应商</label>',
'<div style="position:relative;width:100%" class="bom-sup-wrap"><input id="bomNewProdSup" class="form-input bom-sup-name" style="width:100%;box-sizing:border-box" placeholder="输入关键词" autocomplete="off">',
'<div class="bom-sup-dd" style="position:absolute;top:100%;left:0;right:0;z-index:99999;background:#fff;border:1px solid #d9d9d9;border-radius:4px;max-height:160px;overflow-y:auto;display:none;box-shadow:0 4px 12px rgba(0,0,0,0.12)"></div></div></div>',
'</div>',
'</div>',
'</div>',
'<!-- 包材列表区 -->',
'<div class="pd-card">',
'<div class="pd-card-header" style="display:flex;justify-content:space-between;align-items:center">',
'<span>📦 包材列表 <span style="font-size:12px;color:#888;font-weight:normal">(用量按实际填写,支持多条)</span></span>',
'<button class="btn btn-sm" onclick="bomAddNewRow()" style="padding:2px 10px">+ 添加一行</button>',
'</div>',
'<div class="pd-card-body" style="padding:0;overflow-x:auto">',
'<table class="data-table" style="font-size:12px;width:100%">',
'<thead><tr><th style="width:30px">#</th><th>物料编号 *</th><th>物料名称</th><th style="width:80px;text-align:center">用量 *</th><th>供应商</th><th style="width:80px;text-align:center">成本价</th><th style="width:70px;text-align:center">货品类型</th><th style="width:30px"></th></tr></thead>',
'<tbody id="bomNewMatTbody">',
window._bomAddMatRowHtml(1),
'</tbody>',
'</table>',
'</div>',
'</div>',
'</div>',
'<div class="modal-footer" style="display:flex;justify-content:space-between">',
'<button class="btn" onclick="closeBomAddModal()">取消</button>',
'<button class="btn btn-primary" onclick="submitBomAdd()">💾 保存 BOM</button>',
'</div>',
'</div>'
].join('');
document.body.appendChild(overlay);
// 初始给一行 + 绑定供应商搜索
setTimeout(function() {
var tb = document.getElementById('bomNewMatTbody');
if (tb && !tb.querySelectorAll('tr').length) {
tb.innerHTML = window._bomAddMatRowHtml(1);
}
// 绑定供应商自动搜索
window._bindBomSupEvents(document.querySelectorAll('#bomAddModal .bom-sup-name'));
}, 50);
};
// 新增弹窗 - 物料行HTML
window._bomAddMatRowHtml = function(idx, data) {
data = data || {};
var code = escHtml(String(data.material_code || ''));
var name = escHtml(String(data.material_name || ''));
var qty = data.quantity || 1;
var sup = escHtml(String(data.supplier_name || ''));
var ptype = data.product_type || '包材';
var cost = data.cost_price || 0;
return '<tr class="bom-add-row">' +
'<td style="text-align:center">' + idx + '</td>' +
'<td><input class="form-input bom-add-mat-code" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px" value="' + code + '" placeholder="必填" autocomplete="off"></td>' +
'<td><input class="form-input bom-add-mat-name" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px" value="' + name + '" placeholder="自动匹配" autocomplete="off"></td>' +
'<td><input class="form-input bom-add-mat-qty" type="number" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px;text-align:center" value="' + qty + '" min="0.01" step="0.01" autocomplete="off"></td>' +
'<td style="position:relative"><input class="form-input bom-sup-name" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px" value="' + sup + '" placeholder="输入关键词" autocomplete="off"><div class="bom-sup-dd" style="position:absolute;top:100%;left:0;right:0;z-index:100;background:#fff;border:1px solid #d9d9d9;border-radius:4px;max-height:160px;overflow-y:auto;display:none;box-shadow:0 4px 12px rgba(0,0,0,0.12)"></div></td>' +
'<td><input class="form-input bom-add-mat-cost" type="number" style="width:100%;box-sizing:border-box;padding:4px 6px;font-size:12px;text-align:center" value="' + cost + '" min="0" step="0.01" autocomplete="off"></td>' +
'<td style="text-align:center"><select class="form-input bom-add-mat-type" style="width:100%;box-sizing:border-box;padding:4px 4px;font-size:12px"><option value="包材"' + (ptype === '包材' ? ' selected' : '') + '>包材</option><option value="成品"' + (ptype === '成品' ? ' selected' : '') + '>成品</option><option value="原材料"' + (ptype === '原材料' ? ' selected' : '') + '>原材料</option></select></td>' +
'<td style="text-align:center"><span style="cursor:pointer;color:#999;font-size:14px" onclick="bomAddRemoveRow(this)">✕</span></td>' +
'</tr>';
};
// 新增弹窗 - 添加行
window.bomAddNewRow = function() {
var tb = document.getElementById('bomNewMatTbody');
if (!tb) return;
var idx = tb.querySelectorAll('tr').length + 1;
tb.insertAdjacentHTML('beforeend', window._bomAddMatRowHtml(idx));
window._bindBomSupEvents(tb.querySelectorAll('tr:last-child .bom-sup-name'));
};
// 新增弹窗 - 删除行
window.bomAddRemoveRow = function(el) {
var tr = el.closest('tr');
if (!tr) return;
var tb = tr.closest('tbody');
tr.remove();
// 重新编号
if (tb) {
[].forEach.call(tb.querySelectorAll('tr'), function(r, i) {
r.querySelector('td:first-child').textContent = i + 1;
});
}
};
// 关闭弹窗
window.closeBomAddModal = function() {
var m = document.getElementById('bomAddModal');
if (m) m.remove();
};
// 提交新增BOM
window.submitBomAdd = function() {
var productCode = document.getElementById('bomNewProdCode').value.trim();
if (!productCode) { alert('请输入成品编号'); return; }
var tbody = document.getElementById('bomNewMatTbody');
if (!tbody) return;
var materials = [];
[].forEach.call(tbody.querySelectorAll('.bom-add-row'), function(row) {
var code = (row.querySelector('.bom-add-mat-code')?.value || '').trim();
var name = (row.querySelector('.bom-add-mat-name')?.value || '').trim();
var qty = parseFloat(row.querySelector('.bom-add-mat-qty')?.value) || 1;
var supName = (row.querySelector('.bom-sup-name')?.value || '').trim();
var cost = parseFloat(row.querySelector('.bom-add-mat-cost')?.value) || 0;
var ptype = (row.querySelector('.bom-add-mat-type')?.value || '包材').trim();
if (code) { materials.push({material_code: code, material_name: name, quantity: qty, supplier_name: supName, cost_price: cost, product_type: ptype}); }
});
if (materials.length === 0) { alert('请填写至少一行包材信息'); return; }
var btn = document.querySelector('.btn-primary[onclick*="submitBomAdd"]');
if (btn) { btn.disabled = true; btn.textContent = '保存中...'; }
var prodName = document.getElementById('bomNewProdName')?.value?.trim() || '';
var prodSup = document.getElementById('bomNewProdSup')?.value?.trim() || '';
fetch('/api/bom/create_with_materials', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode, product_name: prodName, product_supplier: prodSup, materials: materials})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (btn) { btn.disabled = false; btn.textContent = '💾 保存 BOM'; }
if (res.code === 0) {
alert('✅ ' + res.msg);
closeBomAddModal();
loadBom(1);
} else {
alert('❌ ' + (res.msg || '保存失败'));
}
})
.catch(function(err) {
if (btn) { btn.disabled = false; btn.textContent = '💾 保存 BOM'; }
alert('❌ ' + (err.message || '网络错误'));
});
};
// ─── BOM导入弹窗 ───
window.openBomImportModal = function() {
var old = document.getElementById('bomImportModal');
if (old) old.remove();
var overlay = document.createElement('div');
overlay.id = 'bomImportModal';
overlay.className = 'modal-overlay';
overlay.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:99999;align-items:center;justify-content:center';
overlay.onclick = function(e) { if (e.target === overlay) {} };
overlay.innerHTML = [
'<div class="modal" style="width:420px">',
'<div class="modal-header"><h3>导入BOM</h3><span class="modal-close" onclick="document.getElementById(\'bomImportModal\').remove()">&times;</span></div>',
'<div class="modal-body" style="padding:20px;text-align:center">',
'<p style="font-size:13px;color:#888;margin-bottom:14px">上传CSV文件导入BOM数据(全量替换)</p>',
'<p style="font-size:12px;color:#999;margin-bottom:14px">CSV格式:货品编号,货品名称,物料编号,物料名称,用量</p>',
'<div style="border:2px dashed #e8e8e8;border-radius:8px;padding:20px;cursor:pointer"',
'onmouseenter="this.style.borderColor=\'var(--primary)\';" onmouseleave="this.style.borderColor=\'#e8e8e8\';"',
'onclick="document.getElementById(\'bomImportFile\').click()">',
'<div style="font-size:36px;margin-bottom:8px">📂</div>',
'<div style="font-size:14px;color:#333">点击选择 CSV 文件</div>',
'</div>',
'<input type="file" id="bomImportFile" accept=".csv" style="display:none" onchange="previewBomImportFile()">',
'<div id="bomImportPreview" style="margin-top:10px;font-size:13px;color:#888"></div>',
'</div>',
'<div class="modal-footer">',
'<button class="btn" onclick="document.getElementById(\'bomImportModal\').remove()">取消</button>',
'<button class="btn btn-primary" id="bomImportBtn" disabled onclick="submitBomImport()">开始导入</button>',
'</div>',
'</div>'
].join('');
document.body.appendChild(overlay);
};
window.previewBomImportFile = function() {
var f = document.getElementById('bomImportFile');
var p = document.getElementById('bomImportPreview');
var b = document.getElementById('bomImportBtn');
if (f.files && f.files.length > 0) {
p.textContent = '已选择:' + f.files[0].name + '(' + (f.files[0].size / 1024).toFixed(1) + 'KB)';
b.disabled = false;
} else {
p.textContent = '';
b.disabled = true;
}
};
window.submitBomImport = function() {
var f = document.getElementById('bomImportFile');
if (!f.files || !f.files[0]) return;
var btn = document.getElementById('bomImportBtn');
var reader = new FileReader();
reader.onload = function(e) {
try {
var text = e.target.result;
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
var lines = text.split('\n').filter(function(l) { return l.trim(); });
var items = [];
lines.forEach(function(line, i) {
if (i === 0 && line.includes('货品编号')) return;
var cols = line.split(',');
if (cols.length >= 4) {
items.push({
product_code: cols[0].trim(),
product_name: (cols[1] || '').trim(),
material_code: cols[2].trim(),
material_name: (cols[3] || '').trim(),
quantity: parseFloat(cols[4]) || 1
});
}
});
if (items.length === 0) { alert('未解析到有效数据'); btn.disabled = false; return; }
btn.disabled = true;
btn.textContent = '导入中...';
fetch('/api/bom/import', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: items})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
alert(res.msg);
document.getElementById('bomImportModal').remove();
loadBom(1);
} else {
alert(res.msg || '导入失败');
document.getElementById('bomImportBtn').disabled = false;
}
})
.catch(function(err) {
alert('请求失败:' + err.message);
document.getElementById('bomImportBtn').disabled = false;
});
} catch(err) {
alert('解析错误:' + err.message);
btn.disabled = false;
}
};
reader.readAsText(f.files[0]);
};
// ─── BOM导出 ───
window.exportBom = function() {
window.open('/api/bom/export', '_blank');
};
// ─── 统一产品详情弹窗(固定字段,参考弹窗展示内容1方案)───
var POPUP_VISIBLE_FIELDS = [
'product_type', '产品种类', 'product_code', 'product_name', '产品分级',
'昨日销量', '销售趋势',
'库存', '加工厂库存', '生产在途', '采购在途',
'日销量预测',
'供应商名称',
'3天平均销量', '7天平均销量', '14天平均销量',
'库存可支撑天数', '加工厂库存支撑天数', '总支撑天数',
'30天平均销量',
'极值判断',
'生产货期', '物流时间',
'创建时间'
];
/** 字段显示名称映射 */
var POPUP_FIELD_LABELS = {
'product_type': '产品类型',
'产品种类': '产品种类',
'product_code': '货品编号',
'product_name': '货品名称',
'产品分级': '产品分级',
'brand': '品牌',
'specification': '规格',
'cost_price': '成本价',
'昨日销量': '昨日销量',
'3天平均销量': '3天平均销量',
'7天平均销量': '7天平均销量',
'14天平均销量': '14天平均销量',
'30天平均销量': '30天平均销量',
'60天平均销量': '60天平均销量',
'销售趋势': '销售趋势',
'库存': '库存',
'加工厂库存': '加工厂库存',
'生产在途': '生产在途',
'采购在途': '采购在途',
'日销量预测': '日销量预测',
'库存可支撑天数': '库存可支撑天数',
'库存工厂可支撑天数': '现货采购在途',
'加工厂库存支撑天数': '加工厂库存支撑',
'库存工厂待生产可支撑天数': '定制品在途',
'总支撑天数': '总支撑天数',
'库存健康指数': '库存健康指数',
'风险把控': '风险把控',
'建议下单数量': '建议下单数量',
'实际下单数量': '实际下单数量',
'建议回货数量': '建议回货数量',
'供应商名称': '供应商名称',
'supplier_code': '供应商编码',
'采购跟单': '采购跟单',
'采购计划': '采购计划',
'运营专员': '运营专员',
'开发专员': '开发专员',
'归属平台': '归属平台',
'生产货期': '生产货期',
'物流时间': '物流时间',
'lead_time': '生产货期',
'logistics_time': '物流时间',
'最低起订量': '最低起订量',
'创建时间': '创建时间',
'是否新品': '是否新品',
'一级类目': '一级类目',
'二级类目': '二级类目',
'三级类目': '三级类目',
'极值判断': '极值判断',
'14天波动率': '14天波动率',
'波动风险': '波动风险',
'3天最高销量': '3天最高销量',
'7天最高销量': '7天最高销量',
'14天最高销量': '14天最高销量',
'30天最高销量': '30天最高销量',
'14天最低销量': '14天最低销量',
'14天标准差': '14天标准差',
'最低销量偏差率': '最低销量偏差率',
'最高销量偏差率': '最高销量偏差率',
};
/** 格式化字段值 */
function formatPopupFieldValue(field, val, p) {
if (val === null || val === undefined || val === '') return '-';
if (field === 'cost_price') return '¥' + parseFloat(val || 0).toFixed(4);
if (field === 'product_type') {
return escHtml(val);
}
if (field === 'product_code' || field === 'product_name') {
return escHtml(val);
}
if (field === '产品分级') {
var gcolors = {'S级':'#f50','A级':'#1890ff','B级':'#52c41a','C级':'#faad14','不参与评级':'#999'};
var gc = gcolors[val] || '#999';
return '<span style="font-weight:500;color:' + gc + '">' + escHtml(val) + '</span>';
}
if (field === '库存健康指数') {
var v = parseFloat(val);
if (isNaN(v) || v === 999) return '<span class="tag tag-grey">999</span>';
if (v >= 1.5) return '<span class="tag tag-green">' + v.toFixed(1) + '</span>';
if (v >= 0.8) return '<span class="tag tag-orange">' + v.toFixed(1) + '</span>';
return '<span class="tag tag-red">' + v.toFixed(1) + '</span>';
}
if (field === '风险把控') {
var rc = {'正常':'tag-green','新品':'tag-blue','呆滞品':'tag-orange','需启动清库存':'tag-red'};
return '<span class="tag ' + (rc[val] || 'tag-grey') + '">' + escHtml(val) + '</span>';
}
if (field === '销售趋势') {
var tc = {
'开始上升':'tag-blue','开始下跌':'tag-orange','平稳趋势':'tag-green',
'持续上升':'tag-red','持续下跌':'tag-purple','上升趋稳':'tag-blue',
'下跌趋稳':'tag-orange','急增':'tag-red','急跌':'tag-red','近期无销售':'tag-grey'
};
var c = tc[val] || 'tag-grey';
return '<span class="tag ' + c + '">' + escHtml(val) + '</span>';
}
if (field === '日销量预测') {
if (val === '近期无销售') return '近期无销售';
return '<span style="color:#1890ff">' + escHtml(val) + '</span>';
}
if (field === '建议下单数量' || field === '实际下单数量') {
var v = parseInt(val);
if (isNaN(v) || v === 0) return '0';
return '<span style="color:var(--primary)">' + v.toLocaleString() + '</span>';
}
if (field === '建议回货数量') {
var v = parseFloat(val);
if (isNaN(v) || v === 0) return '0';
return '<span style="color:#cf1322">' + v.toFixed(1) + '</span>';
}
if (field === '库存可支撑天数' || field === '总支撑天数' || field === '加工厂库存支撑天数' ||
field === '库存工厂可支撑天数' || field === '库存工厂待生产可支撑天数') {
var v = parseFloat(val);
if (isNaN(v)) return '-';
if (v === 999) return '999';
return v.toFixed(1);
}
if (typeof val === 'number') {
return val.toLocaleString();
}
return escHtml(val);
}
// 占位:下单功能(待完善)
// ─── 全选/批量下单 ───
// 全局选中状态(跨页持久化)
if (!window._planSelectedProductCodes) window._planSelectedProductCodes = {};
// ─── 全选/批量下单 ───
if (!window._planSelectedProductCodes) window._planSelectedProductCodes = {};
/** 选择当前页所有行(表头勾选按钮) */
function planToggleCurrentPage(checked) {
document.querySelectorAll('.plan-row-cb').forEach(function(cb) {
cb.checked = checked;
if (checked) {
window._planSelectedProductCodes[cb.value] = true;
} else {
delete window._planSelectedProductCodes[cb.value];
}
});
planSyncSelectUI();
}
/** 全选所有数据(页面头部全选按钮) */
function planToggleAllRows(checked) {
var currentAll = window._planCurrentAllCodes || [];
currentAll.forEach(function(code) {
if (checked) {
window._planSelectedProductCodes[code] = true;
} else {
delete window._planSelectedProductCodes[code];
}
});
// 同步当前页勾选框的视觉状态
document.querySelectorAll('.plan-row-cb').forEach(function(cb) {
cb.checked = window._planSelectedProductCodes[cb.value] || false;
});
planSyncSelectUI();
}
/** 单个勾选变化时同步到全局 */
function planUpdateSelectAll() {
document.querySelectorAll('.plan-row-cb').forEach(function(cb) {
if (cb.checked) {
window._planSelectedProductCodes[cb.value] = true;
} else {
delete window._planSelectedProductCodes[cb.value];
}
});
planSyncSelectUI();
}
/** 统一更新全选框 UI + 选中计数 */
function planSyncSelectUI() {
var all = document.querySelectorAll('.plan-row-cb');
var checked = document.querySelectorAll('.plan-row-cb:checked');
var allPageChecked = all.length > 0 && all.length === checked.length;
document.getElementById('planSelectAllCb').checked = allPageChecked;
// 全选(所有数据)的状态:是否所有 product_code 都被选中
var totalAll = window._planCurrentAllCodes || [];
var allChecked = totalAll.length > 0 && totalAll.every(function(code) { return window._planSelectedProductCodes[code]; });
document.getElementById('planSelectAllCb2').checked = allChecked;
// 选中计数
var selCount = Object.keys(window._planSelectedProductCodes || {}).length;
var scEl = document.getElementById('planSelectedCount');
if (scEl) scEl.textContent = selCount > 0 ? '已选 ' + selCount + ' 个' : '';
}
// ─── 正式下单数量编辑 ───
function editFormalQty(productCode) {
var valSpan = document.getElementById('fqv-' + productCode);
if (!valSpan) return;
var currentVal = parseInt(valSpan.dataset.val) || 0;
var code = productCode;
// 替换为输入框
var input = document.createElement('input');
input.type = 'number';
input.className = 'form-input';
input.style.width = '80px';
input.value = currentVal;
input.dataset.original = currentVal;
valSpan.replaceWith(input);
input.focus();
input.select();
// Enter 确认
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
// 已有确认弹窗打开,忽略重复回车
if (input.dataset.confirming) return;
var newVal = parseInt(input.value);
if (isNaN(newVal) || newVal < 0) newVal = 0;
if (newVal === parseInt(input.dataset.original)) {
replaceBack(code, newVal);
return;
}
// 标记正在确认,阻止 blur 恢复原值
input.dataset.confirming = '1';
showCenteredConfirm('修改确认', '确认将正式下单数量修改为 <strong>' + newVal + '</strong>?').then(function(confirmed) {
delete input.dataset.confirming;
if (confirmed) {
// 保存到后端 - 只更新数量,不创建待下单
fetch('/api/purchase/update_formal_qty', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: code, formal_order_qty: newVal})
});
replaceBack(code, newVal);
} else {
// 取消 → 恢复原值
replaceBack(code, parseInt(input.dataset.original));
}
});
} else if (e.key === 'Escape') {
replaceBack(code, parseInt(input.dataset.original));
}
});
// 没有回车确认就失焦 → 恢复原值
input.addEventListener('blur', function() {
if (input.dataset.confirming) return;
setTimeout(function() {
replaceBack(code, parseInt(input.dataset.original));
}, 200);
});
}
function replaceBack(code, val) {
var input = document.querySelector('.formal-qty-wrap input');
if (!input) return;
var wrap = input.closest('.formal-qty-wrap');
if (!wrap) return;
var newSpan = document.createElement('span');
newSpan.className = 'formal-qty-val';
newSpan.id = 'fqv-' + code;
newSpan.dataset.code = code;
newSpan.dataset.val = val;
newSpan.textContent = val;
input.replaceWith(newSpan);
}
function planBatchOrder() {
var checkedCodes = Object.keys(window._planSelectedProductCodes);
if (checkedCodes.length === 0) {
alert('请先勾选要下单的商品');
return;
}
if (!confirm('确认将选中的 ' + checkedCodes.length + ' 个商品加入待下单列表?')) return;
// 清除待下单页面缓存
if (window._pageCache) delete window._pageCache['purchase'];
fetch('/api/purchase/batch_add_pending', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_codes: checkedCodes})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
var d = res.data || {};
var msg = [];
if (d.added > 0) msg.push('✅ ' + d.added + ' 个已加入待下单列表');
if (d.skipped > 0) msg.push('⚠️ ' + d.skipped + ' 个已在列表中,已跳过');
alert(msg.join('\n') || '操作完成');
} else {
alert('❌ ' + (res.msg || '操作失败'));
}
})
.catch(function() {
alert('❌ 网络错误');
});
}
function showBatchResult(success, skipped) {
var msg = [];
if (success > 0) msg.push('✅ ' + success + ' 个已加入待下单列表');
if (skipped > 0) msg.push('⚠️ ' + skipped + ' 个已在列表中,已跳过');
alert(msg.join('\n') || '操作完成');
}
// 通用居中确认弹窗(返回Promise,resolve(true)=确认, resolve(false)=取消)
window.showCenteredAlert = function(title, message) {
var modal = document.createElement('div');
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);z-index:10000;display:flex;align-items:center;justify-content:center;overflow-y:auto';
modal.innerHTML = '<div style="background:#fff;border-radius:12px;width:440px;max-width:90vw;box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:20px;overflow:hidden">' +
'<div style="padding:24px 24px 12px">' +
'<h3 style="margin:0 0 12px;font-size:17px">' + escHtml(title) + '</h3>' +
'<p style="margin:0;font-size:14px;color:#555;line-height:1.6">' + message + '</p>' +
'</div>' +
'<div style="padding:12px 24px 20px;display:flex;justify-content:flex-end;gap:10px">' +
'<button class="btn btn-primary" id="alertOkBtn">确认</button>' +
'</div></div>';
document.body.appendChild(modal);
document.getElementById('alertOkBtn').onclick = function() { modal.remove(); };
modal.addEventListener('click', function(e) { if (e.target === modal) { modal.remove(); } });
};
window.showCenteredConfirm = function(title, message) {
return new Promise(function(resolve) {
var modal = document.createElement('div');
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);z-index:10000;display:flex;align-items:center;justify-content:center;overflow-y:auto';
modal.innerHTML = '<div style="background:#fff;border-radius:12px;width:440px;max-width:90vw;box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:20px;overflow:hidden">' +
'<div style="padding:24px 24px 12px">' +
'<h3 style="margin:0 0 12px;font-size:17px">' + escHtml(title) + '</h3>' +
'<p style="margin:0;font-size:14px;color:#555;line-height:1.6">' + message + '</p>' +
'</div>' +
'<div style="padding:12px 24px 20px;display:flex;justify-content:flex-end;gap:10px">' +
'<button class="btn" id="confirmCancelBtn">取消</button>' +
'<button class="btn btn-primary" id="confirmOkBtn">确认</button>' +
'</div></div>';
document.body.appendChild(modal);
document.getElementById('confirmCancelBtn').onclick = function() { modal.remove(); resolve(false); };
document.getElementById('confirmOkBtn').onclick = function() { modal.remove(); resolve(true); };
modal.addEventListener('click', function(e) { if (e.target === modal) { modal.remove(); resolve(false); } });
});
};
// 从计划预测中移除产品
window.planDeleteProduct = function(productCode) {
if (!productCode) return;
showCenteredConfirm('确认移除',
'确认将 <strong>[' + escHtml(productCode) + ']</strong> 从计划预测中移除?<br>移除后该产品不再出现在计划预测列表中。'
).then(function(confirmed) {
if (!confirmed) return;
return showCenteredConfirm('⚠️ 二次确认',
'确定要移除 <strong>[' + escHtml(productCode) + ']</strong> 吗?');
}).then(function(confirmed2) {
if (!confirmed2) return;
return fetch('/api/planning/hide', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({product_code: productCode})});
}).then(function(resp) {
return resp.json();
}).then(function(res) {
if (res.code === 0) {
if (typeof loadPlanPage === 'function') loadPlanPage(planCurrentPage || 1);
} else {
alert('❌ ' + (res.msg || '操作失败'));
}
}).catch(function(e) {
console.warn('移除失败:', e);
});
};
window.planOrder = function(data) {
// 支持两种调用方式:传入 product_code 字符串(从缓存取数据)或直接传入数据对象
if (typeof data === 'string') {
var cached = window._planOrderDataCache && window._planOrderDataCache[data];
if (cached) {
try { data = JSON.parse(cached); } catch(e) { data = null; }
} else {
data = null;
}
}
if (!data || !data.product_code) {
alert('数据异常,无法下单');
return;
}
// 从页面读取当前最新的正式下单数量(用户可能已通过 ✏️ 编辑修改过)
var fqvSpan = document.getElementById('fqv-' + data.product_code);
if (fqvSpan) {
var liveQty = parseInt(fqvSpan.dataset.val) || 0;
data.formal_order_qty = liveQty;
}
// 先检查是否已在待下单列表
var pc = data.product_code;
fetch('/api/purchase/check_pending?code=' + encodeURIComponent(pc))
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.exists) {
alert('❌ [' + pc + '] 已在待下单列表中,请勿重复添加');
return;
}
showPlanOrderConfirm(data);
})
.catch(function() {
showPlanOrderConfirm(data);
});
};
function showPlanOrderConfirm(data) {
if (!data || !data.product_code) {
alert('数据异常,无法下单');
return;
}
var pc = data.product_code;
// 先显示 loading 弹窗,同时加载BOM数据
var modal = document.createElement('div');
modal.id = 'planOrderConfirmModal';
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;overflow-y:auto';
modal.innerHTML = '<div style="background:#fff;border-radius:12px;width:580px;max-width:90vw;max-height:85vh;overflow-y:auto;box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:20px">' +
'<div style="padding:24px;text-align:center;color:#999">加载BOM包材数据...</div></div>';
document.body.appendChild(modal);
// 异步加载BOM数据
fetch('/api/product/detail_with_bom?code=' + encodeURIComponent(pc))
.then(function(r) { return r.json(); })
.then(function(res) {
var bomMaterials = (res.code === 0 && res.data && res.data.bom_materials) ? res.data.bom_materials : [];
renderPlanConfirmDialog(modal, data, bomMaterials);
})
.catch(function() {
// 加载失败也继续,无BOM显示
renderPlanConfirmDialog(modal, data, []);
});
}
function renderPlanConfirmDialog(modal, data, bomMaterials) {
var formalQty = data.formal_order_qty || 0;
var hasBom = bomMaterials.length > 0;
// 商品基础信息:2列布局
var confirmHtml = '<div style="padding:20px 24px 8px">';
confirmHtml += '<h3 style="margin-bottom:16px;font-size:17px">确认加入待下单列表?</h3>';
confirmHtml += '<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden">';
confirmHtml += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
var fieldTriples = [
[['货品编号', data.product_code], ['供应商', data.supplier_name], ['归属平台', data.platform]],
[['货品名称', data.product_name], ['货期(天)', data.lead_time], ['采购跟单', data.procurement_follower]],
[['产品类型', data.product_type], ['物流(天)', data.logistics_time], ['采购计划', data.procurement_planner]],
[['产品分级', data.product_grade], ['总支撑天数', data.total_support_days], ['实际下单数量', data.formal_order_qty || 0]]
];
fieldTriples.forEach(function(triple) {
confirmHtml += '<tr>';
for (var t = 0; t < 3; t++) {
var item = triple[t];
if (item) {
confirmHtml += '<td style="padding:7px 10px;border:1px solid #e8e8e8;background:#f5f5f5;color:#999;font-weight:500;white-space:nowrap;width:90px">' + item[0] + '</td>';
confirmHtml += '<td style="padding:7px 10px;border:1px solid #e8e8e8;font-weight:600;width:auto">' + escHtml(String(item[1] || '-')) + '</td>';
} else {
confirmHtml += '<td style="padding:7px 10px;border:1px solid #e8e8e8;background:#f5f5f5;color:#999;font-weight:500;white-space:nowrap;width:90px"></td>';
confirmHtml += '<td style="padding:7px 10px;border:1px solid #e8e8e8"></td>';
}
}
confirmHtml += '</tr>';
});
confirmHtml += '</table></div>';
// BOM包材信息
if (hasBom) {
confirmHtml += '<div style="margin-top:12px;border:1px solid var(--border);border-radius:8px;overflow:hidden">';
confirmHtml += '<div style="background:#fafafa;padding:8px 12px;font-weight:600;font-size:13px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">';
confirmHtml += '<span>📦 包材下单数据</span>';
confirmHtml += '<span style="font-size:12px;color:#999;font-weight:400">共 ' + bomMaterials.length + ' 项</span>';
confirmHtml += '</div>';
confirmHtml += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">';
// 获取之前BOM弹窗保存的包材下单数据
var savedBomMap = {};
var savedRemarkMap = {};
var savedManuscriptMap = {};
if (window._pendingBomData && window._pendingBomData[data.product_code]) {
window._pendingBomData[data.product_code].forEach(function(s) {
savedBomMap[s.material_code] = s.actual_quantity;
savedRemarkMap[s.material_code] = s.remark || '';
savedManuscriptMap[s.material_code] = s.change_manuscript || '';
});
}
var cellBorder = 'border:1px solid #d9d9d9';
var thBase = 'padding:7px 8px;' + cellBorder + ';text-align:center;white-space:nowrap';
var thTight = 'padding:7px 3px;' + cellBorder + ';text-align:center;white-space:nowrap';
confirmHtml += '<thead><tr style="background:#555;color:#fff">';
confirmHtml += '<th style="' + thBase + '">物料编号</th>';
confirmHtml += '<th style="' + thBase + '">物料名称</th>';
confirmHtml += '<th style="' + thTight + '">用量</th>';
confirmHtml += '<th style="' + thTight + '">建议下单数量</th>';
confirmHtml += '<th style="' + thTight + '">实际下单数量</th>';
confirmHtml += '<th style="' + thTight + '">库存</th>';
confirmHtml += '<th style="' + thTight + '">采购在途</th>';
confirmHtml += '<th style="' + thTight + '">生产在途</th>';
confirmHtml += '<th style="' + thTight + '">是否修改稿件</th>';
confirmHtml += '<th style="' + thBase + '">下单备注</th>';
confirmHtml += '</tr></thead><tbody>';
var tdCellBase = 'padding:7px 8px;' + cellBorder + ';text-align:center';
var tdTight = 'padding:7px 3px;' + cellBorder + ';text-align:center';
bomMaterials.forEach(function(b, i) {
var orderQty = Math.ceil(formalQty * (b.bom_qty || 0));
var savedActual = savedBomMap[b.material_code] || orderQty;
var bg2 = i % 2 === 0 ? '#fff' : '#f5f5f5';
confirmHtml += '<tr style="background:' + bg2 + '">';
confirmHtml += '<td style="' + tdCellBase + ';font-weight:600"><code style="font-weight:700;font-size:13px;color:#333">' + escHtml(b.material_code || '') + '</code></td>';
confirmHtml += '<td style="' + tdCellBase + ';font-weight:600;color:#333">' + escHtml(b.material_name || '') + '</td>';
confirmHtml += '<td style="' + tdTight + '">' + (b.bom_qty || 0) + '</td>';
confirmHtml += '<td style="' + tdTight + ';font-weight:600;color:#1890ff">' + orderQty.toLocaleString() + '</td>';
confirmHtml += '<td style="' + tdTight + ';font-weight:600;color:#d4380d">' + (savedActual > 0 ? savedActual.toLocaleString() : '0') + '</td>';
confirmHtml += '<td style="' + tdTight + '">' + (b.available_qty || 0).toLocaleString() + '</td>';
confirmHtml += '<td style="' + tdTight + '">' + (b.purchase_in_transit || 0).toLocaleString() + '</td>';
confirmHtml += '<td style="' + tdTight + '">' + (b.production_in_transit || 0).toLocaleString() + '</td>';
var manuscriptVal = savedManuscriptMap[b.material_code] || '';
var manuscriptDisplay = manuscriptVal === '是' ? '<span style="color:#d4380d;font-weight:600">是</span>' : '<span style="color:#999">否</span>';
confirmHtml += '<td style="' + tdTight + ';font-weight:600">' + manuscriptDisplay + '</td>';
confirmHtml += '<td style="' + tdCellBase + ';min-width:240px;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(savedRemarkMap[b.material_code] || '') + '">' + escHtml(savedRemarkMap[b.material_code] || '') + '</td>';
confirmHtml += '</tr>';
});
confirmHtml += '</tbody></table></div></div>';
}
confirmHtml += '</div>';
confirmHtml += '<div style="padding:16px 24px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:10px">';
confirmHtml += '<button class="btn" id="planOrderCancelBtn">取消</button>';
confirmHtml += '<button class="btn btn-primary" id="planOrderConfirmBtn">✅ 确认加入</button></div>';
modal.innerHTML = '<div style="background:#fff;border-radius:12px;width:' + (hasBom ? '1400px' : '520px') + ';max-width:95vw;max-height:85vh;overflow-y:auto;box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:20px">' +
confirmHtml + '</div>';
document.getElementById('planOrderCancelBtn').onclick = function() { modal.remove(); };
document.getElementById('planOrderConfirmBtn').onclick = function() {
modal.remove();
if (window._pageCache) delete window._pageCache['purchase'];
var orderData = JSON.parse(JSON.stringify(data));
if (window._pendingBomData && window._pendingBomData[data.product_code]) {
orderData.bom_items = JSON.stringify(window._pendingBomData[data.product_code]);
}
fetch('/api/purchase/add_pending', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(orderData)
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
showCenteredAlert('操作成功', '✅ 已加入待下单列表');
} else {
showCenteredAlert('操作失败', '❌ ' + (res.msg || '操作失败'));
}
})
.catch(function() {
showCenteredAlert('网络错误', '❌ 网络错误');
});
};
};
window.showProductDetail = function(productCode) {
if (!productCode) return;
const old = document.getElementById('productDetailModal');
if (old) old.remove();
const modal = document.createElement('div');
modal.id = 'productDetailModal';
modal.className = 'pd-overlay';
modal.style.display = 'flex';
modal.innerHTML = `
<div class="pd-modal pd-modal-wide" style="width:1140px">
<div class="pd-header">
<span class="pd-title">产品详情 \u2014 加载中...</span>
<button class="pd-close-btn" onclick="closeProductDetail()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="pd-body" id="pdBody">
<div class="pd-loading"><div class="loading-spinner"></div><span>加载中...</span></div>
</div>
</div>
`;
document.body.appendChild(modal);
// \u5e76\u884c\u8bf7\u6c42\uff1a\u8ba1\u5212\u6570\u636e + BOM/\u8ba2\u5355/\u9500\u91cf
Promise.all([
fetch('/api/product/planning_detail?code=' + encodeURIComponent(productCode)).then(function(r) { return r.json(); }),
fetch('/api/product/detail_with_bom?code=' + encodeURIComponent(productCode)).then(function(r) { return r.json(); })
]).then(function(results) {
var body = document.getElementById('pdBody');
if (!body) return;
var planRes = results[0];
var bomRes = results[1];
if (planRes.code !== 0) {
body.innerHTML = '<div class="pd-error">' + (planRes.msg || '\u67e5\u8be2\u5931\u8d25') + '</div>';
return;
}
var p = planRes.data;
// \u66f4\u65b0\u6807\u9898\u4e3a\u4ea7\u54c1\u540d\u79f0
var titleEl = modal.querySelector('.pd-title');
if (titleEl) {
titleEl.innerHTML = '\u4ea7\u54c1\u8be6\u60c5 \u2014 <strong>' + escHtml(p.product_name || productCode) + '</strong>';
}
// \u5206\u7ec4\u5b9a\u4e49\uff1a\u57fa\u7840\u4fe1\u606f\uff082\u5217\uff09/ \u9500\u91cf\u4fe1\u606f\uff084\u5217\uff09
var basicFields = ['product_type', '\u4ea7\u54c1\u79cd\u7c7b', 'product_code', 'product_name',
'\u4f9b\u5e94\u5546\u540d\u79f0', '\u751f\u4ea7\u8d27\u671f', '\u7269\u6d41\u65f6\u95f4', '\u521b\u5efa\u65f6\u95f4'];
var salesFields = ['\u4ea7\u54c1\u5206\u7ea7', '\u6628\u65e5\u9500\u91cf', '\u9500\u552e\u8d8b\u52bf', '\u5e93\u5b58', '\u52a0\u5de5\u5382\u5e93\u5b58', '\u751f\u4ea7\u5728\u9014', '\u91c7\u8d2d\u5728\u9014',
'\u65e5\u9500\u91cf\u9884\u6d4b', '3\u5929\u5e73\u5747\u9500\u91cf', '7\u5929\u5e73\u5747\u9500\u91cf', '14\u5929\u5e73\u5747\u9500\u91cf',
'\u5e93\u5b58\u53ef\u652f\u6491\u5929\u6570', '\u52a0\u5de5\u5382\u5e93\u5b58\u652f\u6491\u5929\u6570', '\u603b\u652f\u6491\u5929\u6570',
'30\u5929\u5e73\u5747\u9500\u91cf', '\u6781\u503c\u5224\u65ad'];
// \u751f\u6210\u57fa\u7840\u4fe1\u606f\u5b57\u6bb5HTML\uff082\u5217\u7b49\u8ddd\uff09
var basicHtml = '';
for (var i = 0; i < basicFields.length; i++) {
var field = basicFields[i];
var label = POPUP_FIELD_LABELS[field] || field;
var val = p[field] !== undefined ? p[field] : '-';
var valHtml = formatPopupFieldValue(field, val, p);
basicHtml += '<div class="pd-row pd-row-2col">' +
'<span class="pd-label">' + escHtml(label) + '</span>' +
'<span class="pd-value">' + valHtml + '</span>' +
'</div>';
}
// \u751f\u6210\u9500\u91cf\u4fe1\u606f\u5b57\u6bb5HTML\uff084\u5217\u7b49\u8ddd\uff09
var salesHtml = '';
for (var i = 0; i < salesFields.length; i++) {
var field = salesFields[i];
var label = POPUP_FIELD_LABELS[field] || field;
var val = p[field] !== undefined ? p[field] : '-';
var valHtml = formatPopupFieldValue(field, val, p);
salesHtml += '<div class="pd-row pd-row-4col">' +
'<span class="pd-label">' + escHtml(label) + '</span>' +
'<span class="pd-value">' + valHtml + '</span>' +
'</div>';
}
// BOM\u7269\u6599\u6e05\u5355
var bomHtml = '';
var order = null;
var bomMaterials = [];
var sales = [];
if (bomRes.code === 0) {
order = bomRes.data.order;
bomMaterials = bomRes.data.bom_materials || [];
sales = bomRes.data.sales || [];
}
if (bomMaterials.length > 0) {
var totalMatCost = 0;
bomHtml = bomMaterials.map(function(m) {
return '<tr>' +
'<td style="text-align:center"><code>' + escHtml(m.material_code) + '</code></td>' +
'<td style="text-align:center">' + escHtml(m.material_name) + '</td>' +
'<td style="text-align:center">' + m.available_qty.toLocaleString() + '</td>' +
'<td style="text-align:center">' + m.production_in_transit.toLocaleString() + '</td>' +
'<td style="text-align:center">' + m.purchase_in_transit.toLocaleString() + '</td>' +
'<td style="text-align:center">' + m.material_cost.toFixed(4) + '</td>' +
'</tr>';
}).join('');
}
body.innerHTML = `
<!-- \u57fa\u7840\u4fe1\u606f\u5361\u7247\uff082\u5217\uff09 -->
<div class="pd-card">
<div class="pd-card-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
<span>\u57fa\u7840\u4fe1\u606f</span>
</div>
<div class="pd-card-body">
<div class="pd-fields pd-fields-2col">
${basicHtml}
</div>
</div>
</div>
<!-- \u9500\u91cf\u4fe1\u606f\u5361\u7247\uff084\u5217\uff09 -->
<div class="pd-card">
<div class="pd-card-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>
</svg>
<span>\u9500\u91cf\u4fe1\u606f</span>
<div style="margin-left:12px;display:flex;gap:4px;font-size:12px" id="salesTabBar">
<span class="sales-tab sales-tab-active" data-tab="default" onclick="switchSalesTab('default')" style="cursor:pointer;padding:2px 8px;border-radius:4px;background:#e6f7ff;color:#1890ff;font-weight:600">默认数据</span>
<span class="sales-tab" data-tab="cloud" onclick="switchSalesTab('cloud')" style="cursor:pointer;padding:2px 8px;border-radius:4px;color:#888">云仓数据</span>
<span class="sales-tab" data-tab="external" onclick="switchSalesTab('external')" style="cursor:pointer;padding:2px 8px;border-radius:4px;color:#888">外仓数据</span>
</div>
</div>
<div class="pd-card-body">
<div id="salesTabDefault" class="pd-fields pd-fields-4col">
${salesHtml}
</div>
<div id="salesTabCloud" style="display:none">
<div style="text-align:center;padding:24px;color:var(--text-light)">云仓数据加载中...</div>
</div>
<div id="salesTabExternal" style="display:none">
<div style="text-align:center;padding:24px;color:var(--text-light)">外仓数据加载中...</div>
</div>
</div>
</div>
<!-- BOM\u7269\u6599\u6e05\u5355 -->
<div class="pd-card">
<div class="pd-card-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<span>BOM\u7269\u6599\u6e05\u5355</span>
${order && order.total_order_qty ? '<span style="margin-left:12px;font-size:13px;color:var(--text-light);font-weight:normal">\u6210\u54c1\u4e0b\u5355\u91cf: <strong>' + (order.total_order_qty || 0).toLocaleString() + '</strong> \u4ef6\uff08\u5efa\u8bae: ' + (order.total_suggested_qty || 0).toLocaleString() + '\uff09</span>' : ''}
</div>
<div class="pd-card-body" style="padding:0;overflow-x:auto">
${bomMaterials.length > 0
? '<table class="data-table" style="font-size:12px"><thead><tr><th style="text-align:center">\u7269\u6599\u7f16\u53f7</th><th style="text-align:center">\u7269\u6599\u540d\u79f0</th><th style="text-align:center">\u53ef\u7528\u5e93\u5b58</th><th style="text-align:center">\u751f\u4ea7\u5728\u9014</th><th style="text-align:center">\u91c7\u8d2d\u5728\u9014</th><th style="text-align:center">\u5355\u4ef7</th></tr></thead><tbody>' + bomHtml + '</tbody></table>'
: '<div style="padding:24px;text-align:center;color:var(--text-light)">\u6682\u65e0BOM\u7269\u6599\u6e05\u5355</div>'
}
</div>
</div>
<!-- \u8ba2\u5355\u5728\u9014 -->
<div class="pd-card">
<div class="pd-card-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/>
</svg>
<span>\u8ba2\u5355\u5728\u9014</span>
<span id="orderInTransitLoading" style="margin-left:12px;font-size:13px;color:#9ca3af;font-weight:normal">\u52a0\u8f7d\u4e2d...</span>
</div>
<div class="pd-card-body" style="padding:0;overflow-x:auto" id="orderInTransitBody">
<div style="text-align:center;padding:24px;color:#9ca3af">\u67e5\u8be2\u4e2d...</div>
</div>
</div>
<!-- \u9500\u91cf\u8d8b\u52bf -->
<div class="pd-card">
<div class="pd-card-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>
</svg>
<span>\u9500\u91cf\u8d8b\u52bf</span>
<div style="margin-left:auto;font-size:12px;color:#8c8c8c;cursor:default">
<span style="cursor:pointer;color:#1890ff;font-weight:600" id="chartTypeBar" onclick="switchSalesChartType('bar')">\u67f1\u72b6\u56fe</span>
<span style="color:#d9d9d9;margin:0 4px">/</span>
<span style="cursor:pointer;color:#8c8c8c" id="chartTypeLine" onclick="switchSalesChartType('line')">\u6298\u7ebf\u56fe</span>
</div>
</div>
<div class="pd-card-body">
<div style="height:280px;position:relative">
<canvas id="salesTrendChart"></canvas>
</div>
${sales.length === 0 ? '<div class="pd-no-data">\u6682\u65e0\u9500\u91cf\u6570\u636e</div>' : ''}
</div>
</div>
<div class="pd-footer">
<span>\u521b\u5efa\u65f6\u95f4\uff1a${escHtml(p.created_at || '-')}</span>
<span class="pd-footer-sep">|</span>
<span>\u5bfc\u5165\u65f6\u95f4\uff1a${escHtml(p.import_date || '-')}</span>
</div>
`;
// \u5f02\u6b65\u52a0\u8f7d\u8ba2\u5355\u6570\u636e
fetch('/api/purchase/orders/by_product?code=' + encodeURIComponent(productCode))
.then(function(r) { return r.json(); })
.then(function(res) {
document.getElementById('orderInTransitLoading').textContent = '';
var container = document.getElementById('orderInTransitBody');
if (!container) return;
if (res.code !== 0 || !res.data || !res.data.items || res.data.items.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-light)">\u6682\u65e0\u5728\u9014\u8ba2\u5355</div>';
return;
}
var items = res.data.items;
var summary = res.data.summary;
var statusMap = {'pending':'\u23f3 \u5f85\u5904\u7406','confirmed':'\u2705 \u5df2\u786e\u8ba4','shipped':'\u{1f69a} \u5df2\u53d1\u8d27','received':'\u{1f4e6} \u5df2\u6536\u8d27','cancelled':'\u274c \u5df2\u53d6\u6d88'};
var html = '<table class="data-table" style="font-size:12px"><thead><tr>' +
'<th>\u8ba2\u5355\u7f16\u53f7</th><th>\u72b6\u6001</th><th>\u4e0b\u5355\u65e5\u671f</th><th>\u4e0b\u5355\u91cf</th><th>\u5355\u4ef7</th><th>\u91d1\u989d</th><th>\u8d27\u671f(\u5929)</th>' +
'</tr></thead><tbody>';
items.forEach(function(item) {
var st = statusMap[item.status] || item.status;
html += '<tr>' +
'<td><code>' + escHtml(item.order_no) + '</code></td>' +
'<td>' + st + '</td>' +
'<td>' + (item.order_time || '-') + '</td>' +
'<td style="font-weight:600">' + (item.order_qty || 0).toLocaleString() + '</td>' +
'<td>\u00a5' + parseFloat(item.unit_price || 0).toFixed(2) + '</td>' +
'<td>\u00a5' + parseFloat(item.amount || 0).toLocaleString() + '</td>' +
'<td>' + (item.lead_time || '-') + '</td>' +
'</tr>';
});
html += '</tbody></table>';
html += '<div style="padding:10px 20px;background:#f9fafb;border-top:1px solid #eee;font-size:13px">' +
'\u5171 <strong>' + summary.total_orders + '</strong> \u7b14\u5728\u9014\u8ba2\u5355\uff0c' +
'\u5408\u8ba1\u6570\u91cf: <strong>' + summary.total_qty.toLocaleString() + '</strong>\uff0c' +
'\u5408\u8ba1\u91d1\u989d: <strong>\u00a5' + summary.total_amount.toLocaleString() + '</strong>' +
'</div>';
container.innerHTML = html;
})
.catch(function() {
var el = document.getElementById('orderInTransitLoading');
if (el) el.textContent = '';
var c = document.getElementById('orderInTransitBody');
if (c) c.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-light)">\u67e5\u8be2\u5931\u8d25</div>';
});
// \u9500\u91cf\u56fe\u8868
if (sales.length > 0) {
window._salesData = sales;
window._currentSalesChartType = 'bar';
setTimeout(function() {
renderSalesChart(sales, 'bar');
}, 100);
}
});
};
// ─── BOM管理模式下的简化详情弹窗(仅基础信息 + BOM物料清单)───
window.showBomProductDetail = function(productCode) {
if (!productCode) return;
const old = document.getElementById('productDetailModal');
if (old) old.remove();
const modal = document.createElement('div');
modal.id = 'productDetailModal';
modal.className = 'pd-overlay';
modal.style.display = 'flex';
modal.innerHTML = `
<div class="pd-modal pd-modal-wide" style="width:900px">
<div class="pd-header">
<span class="pd-title">产品详情 \u2014 加载中...</span>
<button class="pd-close-btn" onclick="closeProductDetail()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="pd-body" id="pdBody">
<div class="pd-loading"><div class="loading-spinner"></div><span>加载中...</span></div>
</div>
</div>
`;
document.body.appendChild(modal);
Promise.all([
fetch('/api/product/planning_detail?code=' + encodeURIComponent(productCode)).then(function(r) { return r.json(); }),
fetch('/api/product/detail_with_bom?code=' + encodeURIComponent(productCode)).then(function(r) { return r.json(); })
]).then(function(results) {
var body = document.getElementById('pdBody');
if (!body) return;
var planRes = results[0];
var bomRes = results[1];
if (planRes.code !== 0) {
body.innerHTML = '<div class="pd-error">' + (planRes.msg || '\u67e5\u8be2\u5931\u8d25') + '</div>';
return;
}
var p = planRes.data;
var titleEl = modal.querySelector('.pd-title');
if (titleEl) {
titleEl.innerHTML = '\u4ea7\u54c1\u8be6\u60c5 \u2014 <strong>' + escHtml(p.product_name || productCode) + '</strong>';
}
var basicFields = ['product_type', '\u4ea7\u54c1\u79cd\u7c7b', 'product_code', 'product_name',
'\u4f9b\u5e94\u5546\u540d\u79f0', '\u751f\u4ea7\u8d27\u671f', '\u7269\u6d41\u65f6\u95f4', '\u521b\u5efa\u65f6\u95f4'];
var basicHtml = '';
for (var i = 0; i < basicFields.length; i++) {
var field = basicFields[i];
var label = POPUP_FIELD_LABELS[field] || field;
var val = p[field] !== undefined ? p[field] : '-';
var valHtml = formatPopupFieldValue(field, val, p);
basicHtml += '<div class="pd-row pd-row-2col">' +
'<span class="pd-label">' + escHtml(label) + '</span>' +
'<span class="pd-value">' + valHtml + '</span>' +
'</div>';
}
var bomHtml = '';
var order = null;
var bomMaterials = [];
if (bomRes.code === 0) {
order = bomRes.data.order;
bomMaterials = bomRes.data.bom_materials || [];
}
if (bomMaterials.length > 0) {
bomHtml = bomMaterials.map(function(m) {
return '<tr>' +
'<td style="text-align:center"><code>' + escHtml(m.material_code) + '</code></td>' +
'<td style="text-align:center">' + escHtml(m.material_name) + '</td>' +
'<td style="text-align:center">' + m.available_qty.toLocaleString() + '</td>' +
'<td style="text-align:center">' + m.production_in_transit.toLocaleString() + '</td>' +
'<td style="text-align:center">' + m.purchase_in_transit.toLocaleString() + '</td>' +
'<td style="text-align:center">' + m.material_cost.toFixed(4) + '</td>' +
'</tr>';
}).join('');
}
body.innerHTML = `
<div class="pd-card">
<div class="pd-card-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
<span>\u57fa\u7840\u4fe1\u606f</span>
</div>
<div class="pd-card-body">
<div class="pd-fields pd-fields-2col">
${basicHtml}
</div>
</div>
</div>
<div class="pd-card">
<div class="pd-card-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<span>BOM\u7269\u6599\u6e05\u5355</span>
${order && order.total_order_qty ? '<span style="margin-left:12px;font-size:13px;color:var(--text-light);font-weight:normal">\u6210\u54c1\u4e0b\u5355\u91cf: <strong>' + (order.total_order_qty || 0).toLocaleString() + '</strong> \u4ef6\uff08\u5efa\u8bae: ' + (order.total_suggested_qty || 0).toLocaleString() + '\uff09</span>' : ''}
</div>
<div class="pd-card-body" style="padding:0;overflow-x:auto">
${bomMaterials.length > 0
? '<table class="data-table" style="font-size:12px"><thead><tr><th style="text-align:center">\u7269\u6599\u7f16\u53f7</th><th style="text-align:center">\u7269\u6599\u540d\u79f0</th><th style="text-align:center">\u53ef\u7528\u5e93\u5b58</th><th style="text-align:center">\u751f\u4ea7\u5728\u9014</th><th style="text-align:center">\u91c7\u8d2d\u5728\u9014</th><th style="text-align:center">\u5355\u4ef7</th></tr></thead><tbody>' + bomHtml + '</tbody></table>'
: '<div style="padding:24px;text-align:center;color:var(--text-light)">\u6682\u65e0BOM\u7269\u6599\u6e05\u5355</div>'
}
</div>
</div>
`;
});
};
// 切换销量信息tab页
window.switchSalesTab = function(tab) {
var tabs = ['default', 'cloud', 'external'];
tabs.forEach(function(t) {
var tabEl = document.querySelector('.sales-tab[data-tab="' + t + '"]');
var contentEl = document.getElementById('salesTab' + t.charAt(0).toUpperCase() + t.slice(1));
if (tab === t) {
if (tabEl) { tabEl.style.background = '#e6f7ff'; tabEl.style.color = '#1890ff'; tabEl.style.fontWeight = '600'; }
if (contentEl) contentEl.style.display = '';
} else {
if (tabEl) { tabEl.style.background = ''; tabEl.style.color = '#888'; tabEl.style.fontWeight = ''; }
if (contentEl) contentEl.style.display = 'none';
}
});
};
// ─── 编辑弹窗(动态跟随列显隐)───
// 列字段名 → 可编辑字段映射
const PLAN_EDITABLE_FIELDS = {
'cost_price': {label:'成本价', field:'cost_price', type:'number'},
'product_type': {label:'产品类型', field:'product_type', type:'select', options:['成品','包材','现货','赠品','清库存','其他']},
'产品种类': {label:'产品种类', field:'product_type', type:'text'},
'品牌': {label:'品牌', field:'brand', type:'text'},
'specification': {label:'规格', field:'specification', type:'text'},
'一级类目': {label:'一级类目', field:'category1', type:'text'},
'二级类目': {label:'二级类目', field:'category2', type:'text'},
'三级类目': {label:'三级类目', field:'category3', type:'text'},
'供应商名称': {label:'供应商名称', field:'supplier_name_match', type:'text'},
'供应商编码': {label:'供应商编码', field:'supplier_code', type:'text'},
'生产货期': {label:'货期', field:'lead_time', type:'number'},
'物流时间': {label:'物流时间', field:'logistics_time', type:'number'},
'采购跟单': {label:'采购跟单', field:'procurement_follower', type:'text'},
'采购计划': {label:'采购计划', field:'procurement_planner', type:'text'},
'开发专员': {label:'开发专员', field:'developer', type:'text'},
'归属平台': {label:'归属平台', field:'platform', type:'text'},
'运营专员': {label:'运营专员', field:'operator', type:'text'}
};
// 产品信息管理 - 删除产品
window.deleteProduct = function(productCode) {
if (!productCode) return;
// 弹窗二次确认
var existing = document.getElementById('prodDeleteConfirmOverlay');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.id = 'prodDeleteConfirmOverlay';
overlay.className = 'modal-overlay';
overlay.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:99999;align-items:center;justify-content:center';
overlay.onclick = function(e) { if (e.target === overlay) {} };
overlay.innerHTML = [
'<div class="modal" style="width:380px;text-align:center;padding:0">',
'<div class="modal-header" style="border-bottom:1px solid #f0f0f0;padding:14px 20px;display:flex;align-items:center;gap:8px">',
'<span style="font-size:22px;color:#f5222d">&#9888;</span>',
'<h3 style="margin:0;font-size:15px;color:#f5222d">确认删除</h3>',
'</div>',
'<div class="modal-body" style="padding:24px 20px;font-size:14px;color:#333;line-height:1.6">',
'<p style="margin:0 0 8px">确认删除产品 <strong>' + escHtml(productCode) + '</strong> 吗?</p>',
'<p style="margin:0;color:#f5222d;font-size:13px">删除后将移入回收站,可在回收站中恢复</p>',
'</div>',
'<div class="modal-footer" style="border-top:1px solid #f0f0f0;padding:10px 20px;display:flex;justify-content:center;gap:10px">',
'<button class="btn" onclick="document.getElementById(\'prodDeleteConfirmOverlay\').remove()">取消</button>',
'<button class="btn btn-danger" style="background:#f5222d;color:#fff;border-color:#f5222d" onclick="confirmDeleteProduct(\'' + escHtml(productCode) + '\')">确认删除</button>',
'</div>',
'</div>'
].join('');
document.body.appendChild(overlay);
};
window.confirmDeleteProduct = function(productCode) {
// 关闭确认弹窗
var overlay = document.getElementById('prodDeleteConfirmOverlay');
if (overlay) overlay.remove();
fetch('/api/product/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
delete window._prodRowCache[productCode];
loadProducts(prodCurrentPage);
} else {
alert(res.msg || '删除失败');
}
});
};
// ─── 回收站弹窗功能 ───
window._recyclePageSize = 20;
window._recycleCurrentPage = 1;
window.openRecycleBin = function() {
// 创建弹窗
var old = document.getElementById('recycleBinModal');
if (old) old.remove();
var overlay = document.createElement('div');
overlay.id = 'recycleBinModal';
overlay.className = 'modal-overlay';
overlay.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:99999;align-items:center;justify-content:center';
overlay.onclick = function(e) { if (e.target === overlay) {} };
overlay.innerHTML = [
'<div class="modal" style="width:90vw;max-width:1200px;max-height:90vh;display:flex;flex-direction:column;padding:0">',
'<div class="modal-header" style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #f0f0f0;flex-shrink:0">',
'<span style="font-size:20px">🗑️</span>',
'<h3 style="margin:0;font-size:16px">回收站</h3>',
'<span style="font-size:12px;color:#999">已删除的商品暂存于此</span>',
'<span class="modal-close" onclick="closeRecycleBin()" style="margin-left:auto;font-size:22px;cursor:pointer;color:#999">&times;</span>',
'</div>',
'<div class="modal-body" style="padding:12px 20px;flex:1;overflow:hidden;display:flex;flex-direction:column">',
'<div style="display:flex;gap:8px;margin-bottom:10px;flex-shrink:0">',
'<input type="text" id="rbCode" class="form-input" style="width:160px" placeholder="货品编号" autocomplete="off" onkeydown="if(event.key===\'Enter\')searchRecycleBin(1)">',
'<input type="text" id="rbName" class="form-input" style="width:180px" placeholder="货品名称" autocomplete="off" onkeydown="if(event.key===\'Enter\')searchRecycleBin(1)">',
'<button class="btn btn-primary" onclick="searchRecycleBin(1)">查询</button>',
'<button class="btn" onclick="resetRecycleSearch()">重置</button>',
'<span class="data-count" id="rbCount" style="font-size:13px;color:#666"></span>',
' <span style="margin-left:auto;display:flex;gap:4px">',
' <button class="btn btn-sm" onclick="rbBatchRestore()" style="color:#1890ff;border-color:#1890ff">批量恢复</button>',
' <button class="btn btn-sm" onclick="rbBatchDelete()" style="color:#f5222d;border-color:#f5222d">批量删除</button>',
' </span>',
'</div>',
'<div style="overflow:auto;flex:1;position:relative">',
'<table class="data-table" style="width:100%;font-size:12px;border-collapse:separate">',
'<thead id="rbThead"></thead>',
'<tbody id="rbTbody"><tr><td colspan="99" style="text-align:center;padding:40px;color:#999">加载中...</td></tr></tbody>',
'</table>',
'</div>',
'<div id="rbPagination" class="pagination-controls" style="padding:8px 0 0;flex-shrink:0"></div>',
'</div>',
'</div>'
].join('');
document.body.appendChild(overlay);
loadRecycleBin(1);
};
window.loadRecycleBin = function(page) {
_recycleCurrentPage = page;
// 构建全字段表头
var thead = document.getElementById('rbThead');
if (thead) {
var thHtml = '<tr><th style="width:30px"><input type="checkbox" id="rbCbAll" onchange="rbToggleAll(this.checked)"></th><th style="width:40px">#</th>';
PROD_COLUMNS.forEach(function(c) {
if (c.field === 'cost_price') return; // 成本价不显示在回收站
thHtml += '<th class="col-rb-' + c.field + '" style="white-space:nowrap">' + c.label + '</th>';
});
thHtml += '<th style="width:130px;white-space:nowrap">删除时间</th>';
thHtml += '<th style="width:150px;white-space:nowrap;position:sticky;right:0;z-index:5;background:var(--bg,#fff);border-left:1px solid var(--border)">操作</th></tr>';
thead.innerHTML = thHtml;
}
var code = (document.getElementById('rbCode')?.value || '').trim();
var name = (document.getElementById('rbName')?.value || '').trim();
var url = '/api/products/recycle_bin?page=' + page + '&size=' + _recyclePageSize;
if (code) url += '&prod_code=' + encodeURIComponent(code);
if (name) url += '&prod_name=' + encodeURIComponent(name);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) return;
var data = res.data;
var tbody = document.getElementById('rbTbody');
if (!data.rows || data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="99" style="text-align:center;padding:50px 20px">'
+ '<div style="font-size:36px;margin-bottom:8px">🗑️</div>'
+ '<div style="font-size:14px;color:#999">回收站为空</div></td></tr>';
} else {
var theadEl = document.getElementById('rbThead');
var colCount = (theadEl ? theadEl.querySelectorAll('th').length : PROD_COLUMNS.length + 2);
var typeColors = {'成品':'tag-blue','包材':'tag-orange','赠品':'tag-green','小样':'tag-purple','物料':'tag-orange'};
var html = '';
data.rows.forEach(function(r, i) {
html += '<tr>';
html += '<td style="text-align:center"><input type="checkbox" class="rb-row-cb" value="' + escHtml(r.product_code) + '"></td>';
html += '<td>' + ((page - 1) * _recyclePageSize + i + 1) + '</td>';
PROD_COLUMNS.forEach(function(c) {
if (c.field === 'cost_price') return;
var raw = r[c.field];
var val = raw != null ? String(raw) : '-';
if (c.field === 'created_at') val = raw ? String(raw).slice(0, 10) : '-';
if (c.field === 'import_date') val = raw ? String(raw).slice(0, 10) : '-';
if (c.field === 'product_type') {
var cls = typeColors[raw] || 'tag-grey';
val = '<span class="tag ' + cls + '">' + escHtml(raw || '-') + '</span>';
} else {
val = escHtml(val);
}
html += '<td class="col-rb-' + c.field + '">' + val + '</td>';
});
html += '<td style="color:#999;font-size:12px;white-space:nowrap">' + (r.deleted_at ? r.deleted_at.substring(0, 16) : '-') + '</td>';
html += '<td style="position:sticky;right:0;background:var(--bg,#fff);border-left:1px solid var(--border);z-index:2;white-space:nowrap">';
html += '<button class="btn btn-sm btn-primary" onclick="restoreProduct(\'' + escHtml(r.product_code) + '\')" style="font-size:11px;margin-right:4px">↩ 恢复</button>';
html += '<button class="btn btn-sm btn-outline-danger" onclick="permanentDeleteProduct(\'' + escHtml(r.product_code) + '\')" style="font-size:11px;color:#f5222d;border-color:#f5222d">彻底删除</button></td>';
html += '</tr>';
});
tbody.innerHTML = html;
}
// 分页
var totalPages = Math.max(1, Math.ceil(data.total / _recyclePageSize));
var ph = '';
if (page > 1) ph += '<button class="btn btn-sm" onclick="loadRecycleBin(1)">首页</button><button class="btn btn-sm" onclick="loadRecycleBin(' + (page - 1) + ')">上一页</button>';
ph += '<span style="font-size:13px;color:#666;margin:0 8px">共 ' + data.total + ' 条,第 ' + page + '/' + totalPages + ' 页</span>';
if (page < totalPages) ph += '<button class="btn btn-sm" onclick="loadRecycleBin(' + (page + 1) + ')">下一页</button><button class="btn btn-sm" onclick="loadRecycleBin(' + totalPages + ')">末页</button>';
document.getElementById('rbPagination').innerHTML = ph;
document.getElementById('rbCount').textContent = '共 ' + data.total + ' 条';
});
};
window.searchRecycleBin = function(page) {
loadRecycleBin(page || 1);
};
window.resetRecycleSearch = function() {
var c = document.getElementById('rbCode'); if (c) c.value = '';
var n = document.getElementById('rbName'); if (n) n.value = '';
loadRecycleBin(1);
};
window.restoreProduct = function(productCode) {
if (!confirm('确认恢复产品 ' + productCode + ' 吗?')) return;
fetch('/api/product/restore', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
loadRecycleBin(_recycleCurrentPage);
window._recycleChanged = true;
} else {
alert(res.msg || '恢复失败');
}
});
};
window.permanentDeleteProduct = function(productCode) {
if (!confirm('确认彻底删除产品 ' + productCode + ' 吗?\n此操作不可恢复!')) return;
fetch('/api/product/permanent_delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
loadRecycleBin(_recycleCurrentPage);
window._recycleChanged = true;
} else {
alert(res.msg || '删除失败');
}
});
};
// ─── 回收站全选/批量操作 ───
window.rbToggleAll = function(checked) {
[].forEach.call(document.querySelectorAll('.rb-row-cb'), function(cb) { cb.checked = checked; });
};
window.getSelectedRbCodes = function() {
var codes = [];
[].forEach.call(document.querySelectorAll('.rb-row-cb:checked'), function(cb) { codes.push(cb.value); });
return codes;
};
window.rbBatchRestore = function() {
var codes = window.getSelectedRbCodes();
if (codes.length === 0) { alert('请先勾选需要恢复的商品'); return; }
if (!confirm('确认批量恢复 ' + codes.length + ' 个商品吗?')) return;
var done = 0;
codes.forEach(function(code) {
fetch('/api/product/restore', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: code})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) done++;
if (done === codes.length || done + (codes.length - codes.indexOf(code) - 1) === codes.length) {
// 已有写入保护,取最后一次处理结果
}
});
});
// 延迟刷新列表
setTimeout(function() {
loadRecycleBin(_recycleCurrentPage);
window._recycleChanged = true;
}, 500);
};
window.rbBatchDelete = function() {
var codes = window.getSelectedRbCodes();
if (codes.length === 0) { alert('请先勾选需要删除的商品'); return; }
if (!confirm('确认彻底删除 ' + codes.length + ' 个商品吗?\n此操作不可恢复!')) return;
var done = 0;
codes.forEach(function(code) {
fetch('/api/product/permanent_delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: code})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) done++;
});
});
setTimeout(function() {
loadRecycleBin(_recycleCurrentPage);
window._recycleChanged = true;
}, 500);
};
window.closeRecycleBin = function() {
var modal = document.getElementById('recycleBinModal');
if (modal) modal.remove();
// 如果回收站有改动(恢复/删除),刷新主列表
if (window._recycleChanged) {
window._recycleChanged = false;
loadProducts(prodCurrentPage || 1);
}
};
// 产品信息管理 - 查看详情
window.viewProductDetail = function(productCode) {
if (!productCode) return;
var row = window._prodRowCache && window._prodRowCache[productCode];
if (!row) {
showProductDetailEdit(productCode);
return;
}
var old = document.getElementById('productViewModal');
if (old) old.remove();
var fields = [
{label:'货品编号', val:row.product_code},
{label:'货品名称', val:row.product_name},
{label:'品牌', val:row.brand},
{label:'货品类型', val:row.product_type},
{label:'供应商编码', val:row.supplier_code},
{label:'供应商名称', val:row.supplier_name_match},
{label:'货期(天)', val:row.lead_time},
{label:'物流时间(天)', val:row.logistics_time},
{label:'采购跟单', val:row.procurement_follower},
{label:'采购计划', val:row.procurement_planner},
{label:'平台', val:row.platform},
{label:'运营', val:row.operator},
{label:'开发专员', val:row.developer},
{label:'创建时间', val:(row.created_at||'').toString().slice(0,10)},
{label:'导入日期', val:row.import_date},
];
var html = '<table style="width:auto;border-collapse:collapse;font-size:14px">';
for (var i = 0; i < fields.length; i += 2) {
var left = fields[i];
var right = fields[i + 1];
var bg = (i % 4 === 0) ? '#f9f9f9' : '#fff';
var lv = left.val != null && left.val !== '' ? escHtml(String(left.val)) : '<span style="color:#ccc">-</span>';
html += '<tr style="background:' + bg + '">';
html += '<td style="padding:8px 8px 8px 16px;white-space:nowrap;font-size:12px;color:#666;text-align:right;border-bottom:1px solid #f0f0f0;width:1px;background:#f2f2f2;font-weight:500" nowrap>' + left.label + '</td>';
html += '<td style="padding:8px 24px 8px 4px;white-space:nowrap;font-weight:600;color:#222;border-bottom:1px solid #f0f0f0;border-right:1px solid #e0e0e0;width:auto" nowrap>' + lv + '</td>';
if (right) {
var rv = right.val != null && right.val !== '' ? escHtml(String(right.val)) : '<span style="color:#ccc">-</span>';
html += '<td style="padding:8px 8px 8px 12px;white-space:nowrap;font-size:12px;color:#666;text-align:right;border-bottom:1px solid #f0f0f0;width:1px;background:#f2f2f2;font-weight:500" nowrap>' + right.label + '</td>';
html += '<td style="padding:8px 16px 8px 4px;white-space:nowrap;font-weight:600;color:#222;border-bottom:1px solid #f0f0f0;width:auto" nowrap>' + rv + '</td>';
} else {
html += '<td style="width:1px"></td><td></td>';
}
html += '</tr>';
}
html += '</table>';
var modal = document.createElement('div');
modal.id = 'productViewModal';
modal.className = 'pd-overlay';
modal.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;align-items:center;justify-content:center';
modal.innerHTML = '<div class="pd-modal pd-modal-wide" style="min-width:700px;width:auto;max-width:92vw;max-height:85vh;overflow-y:auto;border-radius:10px">' +
'<div class="pd-header" style="padding:14px 18px;border-bottom:1px solid #eee"><span class="pd-title" style="font-size:16px;font-weight:700">📋 产品详情</span>' +
'<span style="font-size:12px;color:#999;margin:0 6px">——</span>' +
'<span style="font-size:14px;font-weight:600;color:#333">' + escHtml(row.product_name || productCode) + '</span>' +
'<button class="pd-close-btn" onclick="this.closest(\'.pd-overlay\').remove()" style="float:right;background:none;border:none;cursor:pointer;font-size:20px;color:#999;padding:0">&times;</button></div>' +
'<div class="pd-body" style="padding:14px 18px">' + html + '</div></div>';
document.body.appendChild(modal);
};
// 供应商名称→编码查询缓存
if (!window._supplierLookup) {
window._supplierLookup = {};
window._supplierList = [];
fetch('/api/suppliers/list?size=9999')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0 && res.data && res.data.rows) {
window._supplierList = res.data.rows;
res.data.rows.forEach(function(s) {
window._supplierLookup[s.supplier_name] = s.supplier_code;
});
}
});
}
window.lookupSupplierCode = function(name) {
if (!name || !window._supplierList) return;
var matched = null;
if (window._supplierLookup[name]) {
matched = window._supplierLookup[name];
} else {
for (var i = 0; i < window._supplierList.length; i++) {
var sn = window._supplierList[i].supplier_name || '';
if (sn.indexOf(name) >= 0 || name.indexOf(sn) >= 0) {
matched = window._supplierList[i].supplier_code;
break;
}
}
}
var codeEl = document.getElementById('pe_supplier_code');
if (codeEl && matched) codeEl.value = matched;
};
// ─── 新增弹窗:供应商模糊搜索 ───
window.lookupAddSupplierName = function(val) {
var dd = document.getElementById('pa_supDropdown');
if (!dd) return;
if (!val || !window._supplierList) { dd.style.display = 'none'; return; }
var matched = [];
var v = val.toLowerCase();
window._supplierList.forEach(function(s) {
var name = (s.supplier_name || '').toLowerCase();
if (name.indexOf(v) >= 0) matched.push(s);
});
if (matched.length === 0 || matched.length > 50) { dd.style.display = 'none'; return; }
var html = '';
matched.slice(0, 20).forEach(function(s) {
html += '<div style="padding:6px 10px;cursor:pointer;font-size:13px;border-bottom:1px solid #f0f0f0" onmouseenter="this.style.background=\'#e6f7ff\'" onmouseleave="this.style.background=\'\'" onclick="selectAddSupplier(\'' + escJs(s.supplier_code) + '\',\'' + escJs(s.supplier_name) + '\')">';
html += '<span style="font-weight:600">' + escHtml(s.supplier_name) + '</span>';
if (s.supplier_code) html += ' <code style="font-size:11px;color:#999;margin-left:6px">' + escHtml(s.supplier_code) + '</code>';
html += '</div>';
});
dd.innerHTML = html;
dd.style.display = 'block';
};
window.selectAddSupplier = function(code, name) {
document.getElementById('pa_supcode').value = code || '';
document.getElementById('pa_supname').value = name || '';
document.getElementById('pa_supDropdown').style.display = 'none';
};
// 点击其他地方关闭下拉
window.addEventListener('click', function(e) {
var dd = document.getElementById('pa_supDropdown');
if (dd && !e.target.closest('#pa_supname') && !e.target.closest('#pa_supDropdown')) dd.style.display = 'none';
var fd = document.getElementById('pa_folDropdown');
if (fd && !e.target.closest('#pa_follower') && !e.target.closest('#pa_folDropdown')) fd.style.display = 'none';
});
// ─── 采购跟单记忆 ───
window.getFollowerHistory = function() {
var raw = localStorage.getItem('_pa_follower_history');
return raw ? JSON.parse(raw) : [];
};
window.saveFollowerHistory = function(val) {
if (!val) return;
var list = window.getFollowerHistory();
list = list.filter(function(v) { return v !== val; });
list.unshift(val);
if (list.length > 20) list = list.slice(0, 20);
localStorage.setItem('_pa_follower_history', JSON.stringify(list));
};
window.showFollowerHistory = function() {
var dd = document.getElementById('pa_folDropdown');
if (!dd) return;
var input = document.getElementById('pa_follower');
var val = (input.value || '').toLowerCase();
var list = window.getFollowerHistory();
if (val) list = list.filter(function(v) { return v.toLowerCase().indexOf(val) >= 0; });
if (list.length === 0) { dd.style.display = 'none'; return; }
var html = '';
list.forEach(function(v) {
html += '<div style="padding:5px 10px;cursor:pointer;font-size:13px;border-bottom:1px solid #f0f0f0" onmouseenter="this.style.background=\'#f5f5f5\'" onmouseleave="this.style.background=\'\'" onclick="selectFollower(\'' + escJs(v) + '\')">' + escHtml(v) + '</div>';
});
dd.innerHTML = html;
dd.style.display = 'block';
};
window.selectFollower = function(val) {
document.getElementById('pa_follower').value = val;
document.getElementById('pa_folDropdown').style.display = 'none';
};
// 产品信息管理快捷编辑(使用缓存数据,无需等待)
window.quickEditProduct = function(productCode) {
if (!productCode) return;
var row = window._prodRowCache && window._prodRowCache[productCode];
if (!row) {
// 缓存未命中,回退到原始编辑
showProductDetailEdit(productCode);
return;
}
// 关闭已有弹窗
var oldEdit = document.getElementById('productEditModal');
if (oldEdit) oldEdit.remove();
var modal = document.createElement('div');
modal.id = 'productEditModal';
modal.className = 'pd-overlay';
modal.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;align-items:center;justify-content:center';
modal.innerHTML = '<div class="pd-modal pd-modal-wide" style="max-width:650px;max-height:85vh;overflow-y:auto">' +
'<div class="pd-header"><span class="pd-title">✏️ 编辑产品信息</span>' +
'<button class="pd-close-btn" onclick="document.getElementById(\'productEditModal\').remove()">' +
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div>' +
'<div class="pd-body" id="pdEditBody">' + _buildProductEditForm(row) + '</div></div>';
document.body.appendChild(modal);
};
// 构建产品编辑表单
function _buildProductEditForm(row) {
var isNonProd = prodActiveTab === '非成品';
var hideFields = ['brand','lead_time','logistics_time','procurement_follower','procurement_planner','platform','operator','developer','created_at'];
var fields = [
{key:'product_code', label:'货品编号', val:row.product_code, type:'text', ro:true},
{key:'product_name', label:'货品名称', val:row.product_name, type:'text'},
{key:'brand', label:'品牌', val:row.brand, type:'text'},
{key:'product_type', label:'货品类型', val:row.product_type, type:'select', opts:['成品','现货','清库存','赠品','原材料','包材','其他']},
{key:'supplier_code', label:'供应商编码', val:row.supplier_code, type:'text', ro:true},
{key:'supplier_name_match', label:'供应商名称', val:row.supplier_name_match, type:'text', supSearch:true},
{key:'lead_time', label:'货期(天)', val:row.lead_time, type:'number'},
{key:'logistics_time', label:'物流时间(天)', val:row.logistics_time, type:'number', max:999},
{key:'procurement_follower', label:'采购跟单', val:row.procurement_follower, type:'text'},
{key:'procurement_planner', label:'采购计划', val:row.procurement_planner, type:'text'},
{key:'platform', label:'平台', val:row.platform, type:'text'},
{key:'operator', label:'运营', val:row.operator, type:'text'},
{key:'developer', label:'开发专员', val:row.developer, type:'text'},
{key:'created_at', label:'创建时间', val:row.created_at ? String(row.created_at).slice(0,10) : '', type:'date'},
];
if (isNonProd) fields = fields.filter(function(f) { return hideFields.indexOf(f.key) < 0; });
var html = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px 16px">';
fields.forEach(function(f) {
var val = escHtml(String(f.val != null ? f.val : ''));
var roAttr = f.ro ? ' readonly style="background:#f5f5f5;color:#999"' : '';
if (f.type === 'select') {
var opts = '';
(f.opts || []).forEach(function(o) {
var sel = o === f.val ? ' selected' : '';
opts += '<option value="' + o + '"' + sel + '>' + o + '</option>';
});
html += '<div><label style="font-size:12px;color:#888">' + f.label + '</label><select id="pe_' + f.key + '" class="form-input" style="width:100%;box-sizing:border-box">' + opts + '</select></div>';
} else if (f.supSearch) {
html += '<div style="position:relative"><label style="font-size:12px;color:#888">' + f.label + '</label><input id="pe_' + f.key + '" type="text" class="form-input" style="width:100%;box-sizing:border-box" value="' + val + '"' + roAttr + ' autocomplete="off" oninput="editSupSearch(this)" onfocus="editSupSearch(this)"><div class="pe-sup-dd" style="position:absolute;top:100%;left:0;right:0;z-index:100;background:#fff;border:1px solid #d9d9d9;border-radius:4px;max-height:160px;overflow-y:auto;display:none;box-shadow:0 4px 12px rgba(0,0,0,0.12)"></div></div>';
} else {
html += '<div><label style="font-size:12px;color:#888">' + f.label + '</label><input id="pe_' + f.key + '" type="' + f.type + '" class="form-input" style="width:100%;box-sizing:border-box" value="' + val + '"' + roAttr + ' autocomplete="off"></div>';
}
});
html += '</div>';
html += '<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px;padding-top:10px;border-top:1px solid var(--border,#eee)">' +
'<button class="btn" onclick="document.getElementById(\'productEditModal\').remove()">取消</button>' +
'<button class="btn btn-primary" onclick="saveQuickEditProduct(\'' + escHtml(row.product_code) + '\')">保存</button></div>';
return html;
}
// ─── 编辑弹窗供应商模糊搜索 ───
window.editSupSearch = function(input) {
var dd = input.parentElement.querySelector('.pe-sup-dd');
if (!dd) return;
var val = input.value.trim();
if (!val || !window._supplierList) { dd.style.display = 'none'; return; }
var v = val.toLowerCase();
var matched = window._supplierList.filter(function(s) { return (s.supplier_name || '').toLowerCase().indexOf(v) >= 0; });
if (matched.length === 0 || matched.length > 50) { dd.style.display = 'none'; return; }
dd.innerHTML = '';
dd.style.display = 'block';
matched.slice(0, 20).forEach(function(s) {
var item = document.createElement('div');
item.style.cssText = 'padding:4px 8px;cursor:pointer;font-size:12px;border-bottom:1px solid #f0f0f0';
item.className = 'pe-sup-dd-item';
item.dataset.code = s.supplier_code || '';
item.dataset.name = s.supplier_name || '';
item.innerHTML = '<span>' + escHtml(s.supplier_name) + '</span>' + (s.supplier_code ? ' <code style="font-size:10px;color:#999">' + escHtml(s.supplier_code) + '</code>' : '');
dd.appendChild(item);
});
};
window.saveQuickEditProduct = function(productCode) {
var isNonProd = prodActiveTab === '非成品';
var skipFields = ['brand','lead_time','logistics_time','procurement_follower','procurement_planner','platform','operator','developer','created_at'];
var fields = {};
var fieldNames = ['product_name','brand','product_type','supplier_code','supplier_name_match',
'lead_time','logistics_time','procurement_follower','procurement_planner',
'platform','operator','developer','created_at'];
fieldNames.forEach(function(f) {
if (isNonProd && skipFields.indexOf(f) >= 0) return;
var el = document.getElementById('pe_' + f);
if (el) {
var val = el.value;
if (f === 'lead_time' || f === 'logistics_time') val = parseInt(val) || 0;
fields[f] = val;
}
});
fetch('/api/product/batch_update', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode, fields: fields})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
document.getElementById('productEditModal').remove();
loadProducts(prodCurrentPage);
} else {
alert(res.msg || '保存失败');
}
});
};
window.showProductDetailEdit = function(productCode) {
if (!productCode) return;
// 关闭已有弹窗
var oldEdit = document.getElementById('productEditModal');
if (oldEdit) oldEdit.remove();
var modal = document.createElement('div');
modal.id = 'productEditModal';
modal.className = 'pd-overlay';
modal.style.cssText = 'display:flex;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;align-items:center;justify-content:center';
modal.innerHTML = '<div class="pd-modal pd-modal-wide" style="max-width:650px;max-height:85vh;overflow-y:auto">' +
'<div class="pd-header"><span class="pd-title">✏️ 编辑产品信息</span>' +
'<button class="pd-close-btn" onclick="document.getElementById(\'productEditModal\').remove()">' +
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div>' +
'<div class="pd-body" id="pdEditBody"><div class="pd-loading">加载中...</div></div></div>';
document.body.appendChild(modal);
fetch('/api/product/detail_with_bom?code=' + encodeURIComponent(productCode))
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0) {
document.getElementById('pdEditBody').innerHTML = '<div class="pd-error">' + (res.msg || '查询失败') + '</div>';
return;
}
var p = res.data.product;
// 获取当前可见列字段名(来自计划预测页面)
var visibleFields = [];
if (window.planColVisible) {
PLAN_COLUMNS.forEach(function(c) {
if (window.planColVisible[c.field]) {
visibleFields.push(c.field);
}
});
} else {
PLAN_COLUMNS.forEach(function(c) {
if (c.defaultShow) {
visibleFields.push(c.field);
}
});
}
// 过滤出可编辑的字段(按 visibleFields 顺序排列)
var editFields = [];
visibleFields.forEach(function(colField) {
var cfg = PLAN_EDITABLE_FIELDS[colField];
if (cfg) {
// 从产品数据取值
var val = null;
if (colField === '成本价' || colField === 'cost_price') val = p.cost_price;
else if (colField === 'product_type') val = p.product_type;
else if (colField === '产品种类') val = p.产品种类;
else if (colField === '品牌') val = p.brand;
else if (colField === 'specification') val = p.specification;
else if (colField === '一级类目') val = p.category1;
else if (colField === '二级类目') val = p.category2;
else if (colField === '三级类目') val = p.category3;
else if (colField === '供应商名称') val = p.supplier_name;
else if (colField === '供应商编码') val = p.supplier_code;
else if (colField === '生产货期') val = p.lead_time;
else if (colField === '物流时间') val = p.logistics_time;
else if (colField === '采购跟单') val = p.procurement_follower;
else if (colField === '采购计划') val = p.procurement_planner;
else if (colField === '开发专员') val = p.developer;
else if (colField === '归属平台') val = p.platform;
else if (colField === '运营专员') val = p.operator;
editFields.push({cfg: cfg, colField: colField, val: val});
}
});
var html = '<div style="padding:16px 20px">';
html += '<p style="margin-bottom:16px;color:var(--text-secondary);font-size:13px">货品编号: <strong>' + escHtml(productCode) + '</strong> | 货品名称: <strong>' + escHtml(p.product_name || '-') + '</strong></p>';
if (editFields.length > 0) {
html += '<div style="margin-bottom:16px;border:1px solid var(--border);border-radius:8px;overflow:hidden">';
html += '<div style="background:#f5f5f5;padding:8px 14px;font-weight:600;font-size:14px;border-bottom:1px solid var(--border)">可编辑字段</div>';
html += '<div style="padding:10px 14px;display:grid;grid-template-columns:1fr 1fr;gap:8px 16px">';
editFields.forEach(function(ef) {
var f = ef.cfg;
var inputId = 'edit_' + f.field;
html += '<div style="display:flex;flex-direction:column;gap:2px">';
html += '<label for="' + inputId + '" style="font-size:12px;color:var(--text-secondary);font-weight:500">' + f.label + '</label>';
var val = (ef.val !== null && ef.val !== undefined) ? ef.val : '';
if (f.type === 'select' && f.options) {
html += '<select id="' + inputId + '" class="form-input" style="width:100%;box-sizing:border-box">';
f.options.forEach(function(o) {
var sel = (String(val) === String(o)) ? ' selected' : '';
html += '<option value="' + o + '"' + sel + '>' + o + '</option>';
});
html += '</select>';
} else if (f.type === 'number') {
html += '<input type="number" step="0.0001" id="' + inputId + '" class="form-input" value="' + (val || 0) + '" style="width:100%;box-sizing:border-box">';
} else {
html += '<input type="text" id="' + inputId + '" class="form-input" value="' + escHtml(String(val)) + '" style="width:100%;box-sizing:border-box">';
}
html += '</div>';
});
html += '</div></div>';
}
html += '<div style="text-align:right;padding-top:8px">';
html += '<button class="btn" onclick="document.getElementById(\'productEditModal\').remove()" style="margin-right:8px">取消</button>';
if (editFields.length > 0) {
html += '<button class="btn btn-primary" onclick="confirmProductEdit(\'' + escHtml(productCode) + '\')">💾 保存</button>';
}
html += '</div></div>';
document.getElementById('pdEditBody').innerHTML = html;
});
};
window.confirmProductEdit = function(productCode) {
var fields = {};
var changed = false;
// 查找所有以 edit_ 开头的 input 元素
document.querySelectorAll('#productEditModal input[id^="edit_"]').forEach(function(el) {
var fieldName = el.id.replace('edit_', '');
var val = el.value.trim();
if (el.type === 'number') val = parseFloat(val) || 0;
fields[fieldName] = val;
changed = true;
});
if (!changed) {
var nbEl = document.getElementById('editNoChangeTip');
if (!nbEl) {
nbEl = document.createElement('div');
nbEl.id = 'editNoChangeTip';
nbEl.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;padding:24px 32px;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,0.15);z-index:10001;font-size:15px;color:var(--text-secondary)';
nbEl.textContent = '没有可保存的字段';
document.body.appendChild(nbEl);
setTimeout(function() { nbEl.remove(); }, 1500);
}
return;
}
var fieldLabels = {
'cost_price':'成本价', 'product_type':'产品类型', 'brand':'品牌', 'specification':'规格',
'category1':'一级类目', 'category2':'二级类目', 'category3':'三级类目',
'supplier_name_match':'供应商名称', 'supplier_code':'供应商编码',
'lead_time':'货期(天)', 'logistics_time':'物流时间(天)',
'procurement_follower':'采购跟单', 'procurement_planner':'采购计划',
'developer':'开发专员', 'operator':'运营专员', 'platform':'归属平台'
};
// 自定义确认弹窗(替代原生 confirm)
var oldConfirm = document.getElementById('editConfirmModal');
if (oldConfirm) oldConfirm.remove();
var confirmHtml = '';
confirmHtml += '<div style="padding:28px 24px 8px">';
confirmHtml += '<h3 style="margin-bottom:24px;font-size:17px">确认保存以下修改?</h3>';
Object.keys(fields).forEach(function(f) {
confirmHtml += '<div style="display:flex;padding:14px 0;border-bottom:1px solid #f0f0f0;font-size:14px;line-height:1.6">';
confirmHtml += '<span style="flex:0 0 120px;color:var(--text-secondary);font-weight:500">' + (fieldLabels[f] || f) + '</span>';
confirmHtml += '<span style="flex:1;color:var(--text);font-weight:600">' + fields[f] + '</span>';
confirmHtml += '</div>';
});
confirmHtml += '</div>';
var confirmModal = document.createElement('div');
confirmModal.id = 'editConfirmModal';
confirmModal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;overflow-y:auto';
confirmModal.innerHTML = '<div style="background:#fff;border-radius:12px;width:520px;max-width:90vw;max-height:80vh;overflow-y:auto;box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:20px">' +
confirmHtml +
'<div style="padding:16px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:10px">' +
'<button class="btn" onclick="this.closest(\'#editConfirmModal\').remove()">取消</button>' +
'<button class="btn btn-primary" id="editConfirmSaveBtn">确认保存</button></div></div>';
document.body.appendChild(confirmModal);
// 绑定确认按钮
document.getElementById('editConfirmSaveBtn').onclick = function() {
confirmModal.remove();
doSaveProductEdit(productCode, fields);
};
};
window.doSaveProductEdit = function(productCode, fields) {
if (typeof loadPlanPage !== 'function' && typeof planCurrentPage === 'undefined') {
planCurrentPage = 1;
}
fetch('/api/product/batch_update', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: productCode, fields: fields})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
alert('✅ 保存成功');
var editModal = document.getElementById('productEditModal');
if (editModal) editModal.remove();
if (typeof loadPlanPage === 'function') loadPlanPage(planCurrentPage || 1);
} else {
alert('❌ ' + (res.msg || '保存失败'));
}
})
.catch(function() { alert('❌ 网络错误'); });
};
window.showProductDetailFull = function(productCode) {
if (!productCode) return;
const old = document.getElementById('productDetailFullModal');
if (old) old.remove();
const modal = document.createElement('div');
modal.id = 'productDetailFullModal';
modal.className = 'pd-overlay';
modal.style.display = 'flex';
modal.innerHTML = `
<div class="pd-modal pd-modal-wide" style="width:1200px">
<div class="pd-header">
<span class="pd-title">完整产品详情 — <code style="background:#e6f7ff;padding:2px 8px;border-radius:4px;font-size:13px;color:#096dd9">${escHtml(productCode)}</code></span>
<button class="pd-close-btn" onclick="closeProductDetailFull()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="pd-body" id="pdFullBody">
<div class="pd-loading"><div class="loading-spinner"></div><span>加载中...</span></div>
</div>
</div>
`;
document.body.appendChild(modal);
fetch('/api/product/detail_with_bom?code=' + encodeURIComponent(productCode))
.then(r => r.json())
.then(res => {
const body = document.getElementById('pdFullBody');
if (res.code !== 0) {
body.innerHTML = '<div class="pd-error">' + (res.msg || '查询失败') + '</div>';
return;
}
const p = res.data.product;
const order = res.data.order;
const bomMaterials = res.data.bom_materials || [];
const sales = res.data.sales || [];
// BOM表格
var bomHtml = '';
if (bomMaterials.length > 0) {
var totalMatCost = 0;
bomHtml = bomMaterials.map(function(m) {
totalMatCost += m.total_cost;
var shortage = m.required_qty - m.available_qty;
var shortageClass = shortage > 0 ? 'pd-warn' : 'pd-ok';
return '<tr>' +
'<td><code>' + escHtml(m.material_code) + '</code></td>' +
'<td>' + escHtml(m.material_name) + '</td>' +
'<td>' + m.bom_qty + '</td>' +
'<td>' + (m.required_qty || 0).toLocaleString() + '</td>' +
'<td>' + m.available_qty.toLocaleString() + '</td>' +
'<td>' + m.production_in_transit.toLocaleString() + '</td>' +
'<td>' + m.purchase_in_transit.toLocaleString() + '</td>' +
'<td>¥' + m.material_cost.toFixed(4) + '</td>' +
'<td>¥' + m.total_cost.toLocaleString() + '</td>' +
'<td class="' + shortageClass + '">' + (shortage > 0 ? '+' : '') + shortage.toLocaleString() + '</td>' +
'</tr>';
}).join('');
var summaryHtml = '<tfoot><tr style="font-weight:600;background:var(--bg-hover)">' +
'<td colspan="3">合计</td>' +
'<td>' + bomMaterials.reduce(function(s,m){return s+m.required_qty;},0).toLocaleString() + '</td>' +
'<td>' + bomMaterials.reduce(function(s,m){return s+m.available_qty;},0).toLocaleString() + '</td>' +
'<td>' + bomMaterials.reduce(function(s,m){return s+m.production_in_transit;},0).toLocaleString() + '</td>' +
'<td>' + bomMaterials.reduce(function(s,m){return s+m.purchase_in_transit;},0).toLocaleString() + '</td>' +
'<td></td>' +
'<td>¥' + totalMatCost.toLocaleString() + '</td>' +
'<td></td>' +
'</tr></tfoot>';
}
body.innerHTML = '';
// BOM物料清单
if (bomMaterials.length > 0) {
body.innerHTML += '<div class="pd-card">' +
'<div class="pd-card-header">' +
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>' +
'</svg>' +
'<span>BOM物料清单</span>' +
'<span style="margin-left:12px;font-size:13px;color:var(--text-light);font-weight:normal">' +
'成品下单量: <strong>' + (order.total_order_qty || 0).toLocaleString() + '</strong> 件' +
'(建议: ' + (order.total_suggested_qty || 0).toLocaleString() + ')' +
'</span>' +
'</div>' +
'<div class="pd-card-body" style="padding:0;overflow-x:auto">' +
'<table class="data-table" style="font-size:12px">' +
'<thead><tr>' +
'<th>物料编号</th><th>物料名称</th><th>BOM用量</th><th>需用量</th>' +
'<th>可用库存</th><th>生产在途</th><th>采购在途</th>' +
'<th class="col-action" style="width:120px;min-width:120px;position:sticky;right:0;background:var(--bg,#fff);z-index:3;border-left:1px solid var(--border)">操作</th>' +
'<th>单价</th><th>总成本</th><th>缺口</th>' +
'</tr></thead>' +
'<tbody>' + bomHtml + '</tbody>' +
summaryHtml +
'</table>' +
'</div>' +
'</div>';
} else {
body.innerHTML += '<div class="pd-card"><div class="pd-card-body" style="padding:24px;text-align:center;color:var(--text-light)">暂无BOM物料清单</div></div>';
}
// 订单在途
body.innerHTML += '<div class="pd-card"><div class="pd-card-header">' +
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/>' +
'</svg>' +
'<span>订单在途</span>' +
'<span id="orderInTransitLoading" style="margin-left:12px;font-size:13px;color:#9ca3af;font-weight:normal">加载中...</span>' +
'</div>' +
'<div class="pd-card-body" style="padding:0;overflow-x:auto" id="orderInTransitBody">' +
'<div style="text-align:center;padding:24px;color:#9ca3af">查询中...</div>' +
'</div></div>';
// 异步加载订单数据
fetch('/api/purchase/orders/by_product?code=' + encodeURIComponent(productCode))
.then(function(r) { return r.json(); })
.then(function(res) {
document.getElementById('orderInTransitLoading').textContent = '';
var container = document.getElementById('orderInTransitBody');
if (res.code !== 0 || !res.data || !res.data.items || res.data.items.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-light)">暂无在途订单</div>';
return;
}
var items = res.data.items;
var summary = res.data.summary;
var statusMap = {'pending':'⏳ 待处理','confirmed':'✅ 已确认','shipped':'🚚 已发货','received':'📦 已收货','cancelled':'❌ 已取消'};
var html = '<table class="data-table" style="font-size:12px">' +
'<thead><tr>' +
'<th>订单编号</th><th>状态</th><th>下单日期</th><th>下单量</th><th>单价</th><th>金额</th><th>货期(天)</th>' +
'</tr></thead><tbody>';
items.forEach(function(item) {
var st = statusMap[item.status] || item.status;
html += '<tr>' +
'<td><code>' + escHtml(item.order_no) + '</code></td>' +
'<td>' + st + '</td>' +
'<td>' + (item.order_time || '-') + '</td>' +
'<td style="font-weight:600">' + (item.order_qty || 0).toLocaleString() + '</td>' +
'<td>¥' + parseFloat(item.unit_price || 0).toFixed(2) + '</td>' +
'<td>¥' + parseFloat(item.amount || 0).toLocaleString() + '</td>' +
'<td>' + (item.lead_time || '-') + '</td>' +
'</tr>';
});
html += '</tbody></table>';
html += '<div style="padding:10px 20px;background:#f9fafb;border-top:1px solid #eee;font-size:13px">' +
'共 <strong>' + summary.total_orders + '</strong> 笔在途订单,' +
'合计数量: <strong>' + summary.total_qty.toLocaleString() + '</strong>,' +
'合计金额: <strong>¥' + summary.total_amount.toLocaleString() + '</strong>' +
'</div>';
container.innerHTML = html;
})
.catch(function() {
document.getElementById('orderInTransitLoading').textContent = '';
document.getElementById('orderInTransitBody').innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-light)">查询失败</div>';
});
// 销量趋势
body.innerHTML += '<div class="pd-card">' +
'<div class="pd-card-header">' +
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' +
'</svg>' +
'<span>销量趋势</span>' +
'</div>' +
'<div class="pd-card-body">' +
'<div style="height:280px;position:relative">' +
'<canvas id="salesTrendChart"></canvas>' +
'</div>' +
(sales.length === 0 ? '<div class="pd-no-data">暂无销量数据</div>' : '') +
'</div>' +
'</div>';
// 底部信息
body.innerHTML += '<div class="pd-footer">' +
'<span>创建时间:' + (p.created_at || '-') + '</span>' +
'<span class="pd-footer-sep">|</span>' +
'<span>导入时间:' + (p.import_date || '-') + '</span>' +
'</div>';
if (sales.length > 0) {
setTimeout(function() {
renderSalesChart(sales);
}, 100);
}
});
};
window.closeProductDetailFull = function() {
const modal = document.getElementById('productDetailFullModal');
if (modal) modal.remove();
if (window._salesChart) {
window._salesChart.destroy();
window._salesChart = null;
}
};
window.switchSalesChartType = function(type) {
if (!window._salesData) return;
window._currentSalesChartType = type;
var barEl = document.getElementById('chartTypeBar');
var lineEl = document.getElementById('chartTypeLine');
if (barEl && lineEl) {
if (type === 'bar') {
barEl.style.color = '#1890ff'; barEl.style.fontWeight = '600';
lineEl.style.color = '#8c8c8c'; lineEl.style.fontWeight = 'normal';
} else {
barEl.style.color = '#8c8c8c'; barEl.style.fontWeight = 'normal';
lineEl.style.color = '#1890ff'; lineEl.style.fontWeight = '600';
}
}
renderSalesChart(window._salesData, type);
};
function renderSalesChart(sales, chartType) {
chartType = chartType || 'bar';
const canvas = document.getElementById('salesTrendChart');
if (!canvas) return;
if (window._salesChart) {
window._salesChart.destroy();
}
const labels = sales.map(function(s) { return s.date; });
const data = sales.map(function(s) { return s.qty; });
const barColor = 'rgba(24, 144, 255, 0.6)';
const lineColor = '#096dd9';
var datasets = [];
if (chartType === 'bar') {
datasets.push({
label: ' ', // 留空避免图例显示文字
data: data,
backgroundColor: barColor,
borderColor: barColor,
borderWidth: 1,
borderRadius: 2,
order: 1,
yAxisID: 'y'
});
} else {
datasets.push({
label: ' ', // 留空避免图例显示文字
data: data,
borderColor: lineColor,
backgroundColor: 'rgba(9, 109, 217, 0.1)',
borderWidth: 2,
pointBackgroundColor: lineColor,
pointRadius: 4,
pointHoverRadius: 6,
tension: 0.3,
fill: true,
yAxisID: 'y'
});
}
window._salesChart = new Chart(canvas, {
type: chartType === 'bar' ? 'bar' : 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: function(items) { return ''; },
label: function(context) {
if (context.dataset.label === '趋势线') return null;
const idx = context.dataIndex;
const date = labels[idx] || '';
const val = context.raw || 0;
return '日期: ' + date + ' 销量: ' + val.toLocaleString();
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { display: false }
},
y: {
beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.06)' },
ticks: { font: { size: 11 } }
}
}
}
});
}
window.closeProductDetail = function() {
const modal = document.getElementById('productDetailModal');
if (modal) modal.remove();
};
// 通用分页+页码选择器
function changePageSize(newSize, loadFn) {
window._pageSize = parseInt(newSize);
eval(loadFn)(1);
}
function renderPaginationWithSize(el, currentPage, total, pageSize, loadFnName, sizeVarName) {
if (!el) return;
const totalPages = Math.ceil(total / pageSize);
if (totalPages <= 1 && total <= pageSize) {
el.innerHTML = '<div class="pagination-inner"><span class="page-info">共 ' + total + ' 条</span></div>';
return;
}
let html = '<div class="pagination-inner">';
html += '<button class="btn btn-sm" onclick="' + loadFnName + '(1)" ' + (currentPage <= 1 ? 'disabled' : '') + '>&#9664;&#9664;</button>';
html += '<button class="btn btn-sm" onclick="' + loadFnName + '(' + (currentPage - 1) + ')" ' + (currentPage <= 1 ? 'disabled' : '') + '>&#9664;</button>';
const start = Math.max(1, currentPage - 2);
const end = Math.min(totalPages, currentPage + 2);
for (let i = start; i <= end; i++) {
html += '<button class="btn btn-sm ' + (i === currentPage ? 'btn-primary' : '') + '" onclick="' + loadFnName + '(' + i + ')">' + i + '</button>';
}
html += '<button class="btn btn-sm" onclick="' + loadFnName + '(' + (currentPage + 1) + ')" ' + (currentPage >= totalPages ? 'disabled' : '') + '>&#9654;</button>';
html += '<button class="btn btn-sm" onclick="' + loadFnName + '(' + totalPages + ')" ' + (currentPage >= totalPages ? 'disabled' : '') + '>&#9654;&#9654;</button>';
html += '<span class="page-info">' + currentPage + '/' + totalPages + '页 共' + total + '条</span>';
html += '<span class="goto-label" style="margin-left:10px">跳转</span>';
html += '<input class="goto-input" type="number" min="1" max="' + totalPages + '" value="' + currentPage + '" ';
html += 'onkeydown="if(event.key===\'Enter\'){var v=parseInt(this.value);if(v>=1&&v<=' + totalPages + ')' + loadFnName + '(v);}" ';
html += 'onblur="if(this.value===\'\'||parseInt(this.value)<1||parseInt(this.value)>' + totalPages + ')this.value=' + currentPage + ';" ';
html += 'onfocus="this.select()">';
html += '<span class="goto-label">页</span>';
html += '<span class="page-size-label" style="margin-left:10px">每页</span> ';
html += '<select class="page-size-select" onchange="' + sizeVarName + '=parseInt(this.value);' + loadFnName + '(1);">';
[15,20,50,100,200].forEach(function(s) {
html += '<option value="' + s + '"' + (s == pageSize ? ' selected' : '') + '>' + s + '</option>';
});
html += '</select> <span class="page-size-label">条</span>';
html += '</div>';
el.innerHTML = html;
}
// ─── 供应商编辑弹窗 ───
window.editSupplier = function(code) {
if (!code) return;
fetch('/api/suppliers/list?size=1&code=' + encodeURIComponent(code))
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 0 || !res.data.rows || res.data.rows.length === 0) return;
var s = res.data.rows[0];
var old = document.getElementById('editSupplierModal');
if (old) old.remove();
var html = '<div style="padding:20px">';
html += '<p style="margin-bottom:16px;color:var(--text-secondary);font-size:13px">编码: <strong>' + escHtml(code) + '</strong></p>';
html += '<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;padding:14px;display:grid;grid-template-columns:1fr 1fr;gap:10px 16px">';
var fields = [
{label:'供应商名称', id:'es_name', val:s.supplier_name, type:'text'},
{label:'做货类型', id:'es_ptype', val:s.production_type, type:'text'},
{label:'简称', id:'es_sname', val:s.short_name, type:'text'},
{label:'供应商分组', id:'es_group', val:s.supplier_group, type:'text'},
{label:'审核人', id:'es_reviewer', val:s.reviewer, type:'text'},
{label:'审核日期', id:'es_rdate', val:s.review_date || '', type:'text'},
{label:'数据状态', id:'es_dstatus', val:s.data_status, type:'text'},
{label:'禁用状态', id:'es_bstatus', val:s.disabled_status, type:'text'}
];
fields.forEach(function(f) {
html += '<div style="display:flex;flex-direction:column;gap:3px"><label style="font-size:12px;color:var(--text-secondary)">' + f.label + '</label>';
html += '<input type="text" id="' + f.id + '" class="form-input" value="' + escHtml(String(f.val || '')) + '"></div>';
});
html += '</div>';
html += '<div style="text-align:right;margin-top:16px">';
html += '<button class="btn" onclick="document.getElementById(\'editSupplierModal\').remove()">取消</button>';
html += '<button class="btn btn-primary" onclick="confirmSupplierEdit(\'' + escHtml(code) + '\')" style="margin-left:8px">💾 保存</button></div></div>';
var modal = document.createElement('div');
modal.id = 'editSupplierModal';
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center';
modal.innerHTML = '<div style="background:#fff;border-radius:12px;width:500px;max-width:90vw;box-shadow:0 8px 24px rgba(0,0,0,0.15);margin:20px">' +
'<div class="pd-header"><span class="pd-title">✏️ 编辑供应商</span>' +
'<button class="pd-close-btn" onclick="this.closest(\'#editSupplierModal\').remove()">×</button></div>' + html + '</div>';
document.body.appendChild(modal);
});
};
window.confirmSupplierEdit = function(code) {
var fields = {
supplier_name: document.getElementById('es_name')?.value?.trim() || '',
production_type: document.getElementById('es_ptype')?.value?.trim() || '',
short_name: document.getElementById('es_sname')?.value?.trim() || '',
supplier_group: document.getElementById('es_group')?.value?.trim() || '',
reviewer: document.getElementById('es_reviewer')?.value?.trim() || '',
review_date: document.getElementById('es_rdate')?.value?.trim() || '',
data_status: document.getElementById('es_dstatus')?.value?.trim() || '',
disabled_status: document.getElementById('es_bstatus')?.value?.trim() || ''
};
if (!confirm('确认保存供应商修改?')) return;
fetch('/api/supplier/update', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({supplier_code: code, fields: fields})
}).then(function(r) { return r.json(); }).then(function(res) {
if (res.code === 0) { alert('✅ 保存成功'); document.getElementById('editSupplierModal').remove(); loadSuppliers(supCurrentPage); }
else { alert('❌ ' + (res.msg || '保存失败')); }
}).catch(function() { alert('❌ 网络错误'); });
};
// ─── 删除供应商 ───
window.deleteSupplier = function(code) {
if (!code) return;
if (!confirm('确认删除供应商 [' + code + '] 吗?此操作不可恢复!')) return;
fetch('/api/supplier/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({supplier_code: code})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 0) {
alert('✅ ' + (res.msg || '删除成功'));
loadSuppliers(supCurrentPage);
} else {
alert('❌ ' + (res.msg || '删除失败'));
}
})
.catch(function() { alert('❌ 网络错误'); });
};