12931 lines
686 KiB
Plaintext
12931 lines
686 KiB
Plaintext
// 蝶雅采购智能体平台 - 前端逻辑
|
||
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()">×</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'">×</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, '"'); }
|
||
function escHtml(str) {
|
||
if (str === null || str === undefined) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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('fbFileInput').click()" ondrop="handleFbDrop(event)" ondragover="event.preventDefault();this.classList.add('dragover')" ondragleave="this.classList.remove('dragover')">📎 点击选择文件,或将文件拖拽到此处</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()">×</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()">×</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()">×</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">×</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()">×</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()">×</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' : '') + '>◀◀</button>';
|
||
ph += '<button class="btn btn-sm" onclick="fetchWarehouses(' + (whCurrentPage - 1) + ')" ' + (whCurrentPage <= 1 ? 'disabled' : '') + '>◀</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' : '') + '>▶</button>';
|
||
ph += '<button class="btn btn-sm" onclick="fetchWarehouses(' + totalPages + ')" ' + (whCurrentPage >= totalPages ? 'disabled' : '') + '>▶▶</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\'">×</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()">×</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()">×</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()">×</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()">×</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()">×</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()">×</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,'"') + '"' : '') + '>' + 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()">×</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()">×</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'">×</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'">×</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()">×</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">⚠</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">×</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">×</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' : '') + '>◀◀</button>';
|
||
html += '<button class="btn btn-sm" onclick="' + loadFnName + '(' + (currentPage - 1) + ')" ' + (currentPage <= 1 ? 'disabled' : '') + '>◀</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' : '') + '>▶</button>';
|
||
html += '<button class="btn btn-sm" onclick="' + loadFnName + '(' + totalPages + ')" ' + (currentPage >= totalPages ? 'disabled' : '') + '>▶▶</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('❌ 网络错误'); });
|
||
};
|
||
|
||
|