527 lines
19 KiB
HTML
527 lines
19 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{{ box.name }} - 容器详情</title>
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||
</head>
|
||
<body>
|
||
<header class="hero slim">
|
||
<div>
|
||
<h1>{{ box.name }} - {{ box_types.get(box.box_type, box_types['small_28']).label }}</h1>
|
||
<p>核心操作: 新增/编辑/快速入库,路径最短化</p>
|
||
</div>
|
||
<nav class="hero-actions">
|
||
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
|
||
{% if box.box_type == 'bag' %}
|
||
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
|
||
{% else %}
|
||
<a class="btn btn-light" href="{{ url_for('type_page', box_type=box.box_type) }}">返回上一级容器</a>
|
||
{% endif %}
|
||
<a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
|
||
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
|
||
<a class="btn btn-light" href="{{ url_for('export_box_labels_csv', box_id=box.id) }}">导出打标CSV</a>
|
||
</nav>
|
||
</header>
|
||
|
||
<main class="container">
|
||
{% if error %}
|
||
<p class="alert">{{ error }}</p>
|
||
{% endif %}
|
||
{% if notice %}
|
||
<p class="notice">{{ notice }}</p>
|
||
{% endif %}
|
||
|
||
<div class="entry-shell">
|
||
<section class="entry-main">
|
||
<p class="group-desc">容量: {{ box.slot_capacity }} 位 | 编号范围: {{ slot_range }}</p>
|
||
<div class="slot-toolbar">
|
||
<button class="btn btn-light" type="button" id="slot-density-toggle" aria-pressed="false">切换到精简模式</button>
|
||
</div>
|
||
<section class="slot-grid{% if box.box_type == 'small_28' %} slot-grid-28-fixed{% endif %}{% if box.box_type == 'medium_14' %} slot-grid-14-fixed{% endif %}{% if box.box_type == 'bag' %} slot-grid-bag-fixed{% elif box.slot_capacity <= 4 %} slot-grid-bag{% endif %}">
|
||
{% for item in slots %}
|
||
<a class="slot {% if item.component %}filled{% endif %}{% if item.component and item.component.quantity < low_stock_threshold %} low-stock{% endif %}" href="{{ url_for('edit_component', box_id=box.id, slot=item.slot) }}">
|
||
<span class="slot-no">{{ item.slot_code }}</span>
|
||
{% if item.component %}
|
||
<small class="slot-part" title="{{ item.component.part_no }}">{{ item.component.part_no }}</small>
|
||
<small class="slot-name" title="{{ item.component.name }}"><span class="slot-name-text">{{ item.component.name }}</span></small>
|
||
<div class="slot-details">
|
||
{% if item.spec_fields.package %}
|
||
<small class="slot-field" title="封装">封装: {{ item.spec_fields.package }}</small>
|
||
{% endif %}
|
||
{% if item.spec_fields.usage %}
|
||
<small class="slot-field" title="用途/分类">用途: {{ item.spec_fields.usage }}</small>
|
||
{% endif %}
|
||
</div>
|
||
<small class="slot-meta">数量: {{ item.component.quantity }}</small>
|
||
<div class="slot-details">
|
||
{% if item.lcsc_code %}
|
||
<small class="slot-lcsc" title="点击复制立创编号" data-copy="{{ item.lcsc_code }}">编号: {{ item.lcsc_code }}</small>
|
||
{% endif %}
|
||
</div>
|
||
{% if item.component.quantity < low_stock_threshold %}
|
||
<small class="slot-alert">低库存预警</small>
|
||
{% endif %}
|
||
{% else %}
|
||
<small class="slot-meta">空位</small>
|
||
{% endif %}
|
||
</a>
|
||
{% endfor %}
|
||
</section>
|
||
|
||
<div class="modal-backdrop" id="quick-inbound-modal" hidden>
|
||
<div class="modal-card panel" role="dialog" aria-modal="true" aria-labelledby="quick-inbound-title">
|
||
<div class="group-title-row">
|
||
<h2 id="quick-inbound-title">快速入库</h2>
|
||
<button class="btn btn-light" type="button" id="close-quick-inbound">关闭</button>
|
||
</div>
|
||
<p class="hint">每行一条: 料号, 名称, 数量, 规格, 备注。支持英文逗号或Tab分隔;先点“AI预处理”查看结构化结果,再确认导入。</p>
|
||
<form method="post" id="quick-inbound-form" action="{% if box.box_type == 'bag' %}{{ url_for('add_bag_items_batch', box_id=box.id) }}{% else %}{{ url_for('quick_inbound', box_id=box.id) }}{% endif %}">
|
||
<input type="hidden" id="ai-inbound-mode" value="{% if box.box_type == 'bag' %}bag{% else %}box{% endif %}">
|
||
<textarea class="batch-input" id="quick-inbound-lines" name="lines" rows="8" placeholder="10K-0603, 电阻10K 0603, 500, 1%, 常用\n100nF-0603, 电容100nF 0603, 300, 50V X7R, 去耦"></textarea>
|
||
<label>
|
||
<input type="checkbox" id="ai-inbound-web-search" checked>
|
||
联网补全(适合只有简短描述、来源非嘉立创的物料)
|
||
</label>
|
||
<p class="hint">建议: part_no 用厂家型号,name 用品类+型号,specification 只写关键参数。</p>
|
||
<p class="hint" id="ai-inbound-status" aria-live="polite"></p>
|
||
<section class="ai-preview" id="ai-inbound-preview" hidden>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>行</th>
|
||
<th>料号</th>
|
||
<th>名称</th>
|
||
<th>数量</th>
|
||
<th>规格</th>
|
||
<th>备注</th>
|
||
<th>识别提示</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="ai-inbound-preview-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
<div class="actions">
|
||
<button class="btn btn-light" type="button" id="ai-inbound-parse-btn">AI预处理并预览</button>
|
||
<button class="btn btn-light" type="button" id="ai-inbound-show-sources-btn" disabled>查看补全来源</button>
|
||
<button class="btn btn-light" type="button" id="ai-inbound-export-invalid-btn" disabled>导出异常行</button>
|
||
<button class="btn" type="submit">确认导入</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-backdrop" id="ai-inbound-sources-modal" hidden>
|
||
<div class="modal-card panel" role="dialog" aria-modal="true" aria-labelledby="ai-inbound-sources-title">
|
||
<div class="group-title-row">
|
||
<h2 id="ai-inbound-sources-title">联网补全来源明细</h2>
|
||
<button class="btn btn-light" type="button" id="close-ai-inbound-sources">关闭</button>
|
||
</div>
|
||
<p class="hint">仅作为补全参考,请以实物参数、数据手册或采购页面为准。</p>
|
||
<div id="ai-inbound-sources-body" class="ai-source-list"></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<aside class="entry-sidebar">
|
||
{% if box.box_type == 'bag' %}
|
||
<section class="panel quick-inbound-panel">
|
||
<h2>袋位设置</h2>
|
||
<p class="hint">袋装清单是固定大容器,但袋位数量可以按实际需要调整。</p>
|
||
<form class="form-grid" method="post" action="{{ url_for('update_bag_capacity', box_id=box.id) }}">
|
||
<label>
|
||
袋位数量
|
||
<input type="number" name="slot_capacity" min="1" value="{{ box.slot_capacity }}">
|
||
</label>
|
||
<div class="actions full">
|
||
<button class="btn" type="submit">更新袋位数量</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
{% endif %}
|
||
|
||
<section class="panel quick-inbound-panel">
|
||
<h2>快速入库</h2>
|
||
<div class="card-actions quick-inbound-entry">
|
||
<button class="btn btn-light" type="button" id="open-quick-inbound">打开快速入库</button>
|
||
</div>
|
||
<p class="hint">弹窗录入,不占主页面空间。</p>
|
||
</section>
|
||
|
||
<section class="panel entry-guide">
|
||
<h2>轻量入库规范</h2>
|
||
<p class="hint">先保证可检索,再补充关键参数,不追求一次填很全。</p>
|
||
<ul class="guide-list">
|
||
<li>必填: 料号(part_no) + 名称(name) + 数量(quantity)</li>
|
||
<li>建议: 规格(specification)写 2-4 个关键参数</li>
|
||
<li>备注(note): 来源编号或链接,如 LCSC item 9243</li>
|
||
</ul>
|
||
<pre class="guide-code">料号: STM32F103C8T6
|
||
名称: MCU STM32F103C8T6
|
||
规格: Cortex-M3 / 64KB Flash / LQFP-48
|
||
数量: 10
|
||
备注: LCSC item 9243</pre>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
</main>
|
||
<script>
|
||
(function () {
|
||
var grid = document.querySelector('.slot-grid');
|
||
var toggleBtn = document.getElementById('slot-density-toggle');
|
||
if (!grid || !toggleBtn) {
|
||
return;
|
||
}
|
||
|
||
var storageKey = 'slot-density-mode';
|
||
var mode = window.localStorage.getItem(storageKey) || 'detailed';
|
||
|
||
function applyMode(nextMode) {
|
||
var compact = nextMode === 'compact';
|
||
grid.classList.toggle('compact', compact);
|
||
toggleBtn.textContent = compact ? '切换到详细模式' : '切换到精简模式';
|
||
toggleBtn.setAttribute('aria-pressed', compact ? 'true' : 'false');
|
||
}
|
||
|
||
applyMode(mode);
|
||
|
||
toggleBtn.addEventListener('click', function () {
|
||
mode = mode === 'compact' ? 'detailed' : 'compact';
|
||
window.localStorage.setItem(storageKey, mode);
|
||
applyMode(mode);
|
||
});
|
||
})();
|
||
|
||
(function () {
|
||
var openBtn = document.getElementById('open-quick-inbound');
|
||
var closeBtn = document.getElementById('close-quick-inbound');
|
||
var modal = document.getElementById('quick-inbound-modal');
|
||
|
||
if (!openBtn || !modal) {
|
||
return;
|
||
}
|
||
|
||
function openModal() {
|
||
modal.hidden = false;
|
||
document.body.classList.add('modal-open');
|
||
}
|
||
|
||
function closeModal() {
|
||
modal.hidden = true;
|
||
document.body.classList.remove('modal-open');
|
||
}
|
||
|
||
openBtn.addEventListener('click', openModal);
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', closeModal);
|
||
}
|
||
|
||
modal.addEventListener('click', function (event) {
|
||
if (event.target === modal) {
|
||
closeModal();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('keydown', function (event) {
|
||
if (event.key === 'Escape' && !modal.hidden) {
|
||
closeModal();
|
||
}
|
||
});
|
||
})();
|
||
|
||
(function () {
|
||
var codeNodes = document.querySelectorAll('.slot-lcsc[data-copy]');
|
||
if (!codeNodes.length) {
|
||
return;
|
||
}
|
||
|
||
codeNodes.forEach(function (node) {
|
||
node.addEventListener('click', function (event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
var text = (node.getAttribute('data-copy') || '').trim();
|
||
if (!text || !navigator.clipboard || !navigator.clipboard.writeText) {
|
||
return;
|
||
}
|
||
navigator.clipboard.writeText(text).then(function () {
|
||
node.classList.add('copied');
|
||
window.setTimeout(function () {
|
||
node.classList.remove('copied');
|
||
}, 900);
|
||
});
|
||
});
|
||
});
|
||
})();
|
||
|
||
(function () {
|
||
var parseBtn = document.getElementById('ai-inbound-parse-btn');
|
||
var showSourcesBtn = document.getElementById('ai-inbound-show-sources-btn');
|
||
var sourcesModal = document.getElementById('ai-inbound-sources-modal');
|
||
var closeSourcesBtn = document.getElementById('close-ai-inbound-sources');
|
||
var sourcesBody = document.getElementById('ai-inbound-sources-body');
|
||
var exportInvalidBtn = document.getElementById('ai-inbound-export-invalid-btn');
|
||
var textarea = document.getElementById('quick-inbound-lines');
|
||
var modeInput = document.getElementById('ai-inbound-mode');
|
||
var webSearchInput = document.getElementById('ai-inbound-web-search');
|
||
var status = document.getElementById('ai-inbound-status');
|
||
var preview = document.getElementById('ai-inbound-preview');
|
||
var previewBody = document.getElementById('ai-inbound-preview-body');
|
||
var latestRows = [];
|
||
var latestWebContext = [];
|
||
|
||
if (!parseBtn || !textarea || !status || !preview || !previewBody || !exportInvalidBtn || !showSourcesBtn || !sourcesBody) {
|
||
return;
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
return String(text || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function renderRows(rows) {
|
||
latestRows = Array.isArray(rows) ? rows : [];
|
||
var invalidCount = latestRows.filter(function (row) {
|
||
return !row.is_valid;
|
||
}).length;
|
||
exportInvalidBtn.disabled = invalidCount <= 0;
|
||
|
||
if (!Array.isArray(rows) || !rows.length) {
|
||
preview.hidden = true;
|
||
previewBody.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
var html = rows.map(function (row) {
|
||
var messages = [];
|
||
(row.errors || []).forEach(function (msg) {
|
||
messages.push('错误: ' + msg);
|
||
});
|
||
(row.warnings || []).forEach(function (msg) {
|
||
messages.push('提示: ' + msg);
|
||
});
|
||
var tips = messages.length ? messages.join('<br>') : '正常';
|
||
var rowClass = row.is_valid ? '' : ' class="row-invalid"';
|
||
|
||
return '<tr' + rowClass + '>'
|
||
+ '<td>' + escapeHtml(row.line_no) + '</td>'
|
||
+ '<td>' + escapeHtml(row.part_no) + '</td>'
|
||
+ '<td>' + escapeHtml(row.name) + '</td>'
|
||
+ '<td>' + escapeHtml(row.quantity) + '</td>'
|
||
+ '<td>' + escapeHtml(row.specification) + '</td>'
|
||
+ '<td>' + escapeHtml(row.note) + '</td>'
|
||
+ '<td>' + tips + '</td>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
|
||
previewBody.innerHTML = html;
|
||
preview.hidden = false;
|
||
}
|
||
|
||
function renderWebContext(contextRows) {
|
||
latestWebContext = Array.isArray(contextRows) ? contextRows : [];
|
||
showSourcesBtn.disabled = latestWebContext.length <= 0;
|
||
|
||
if (!latestWebContext.length) {
|
||
sourcesBody.innerHTML = '<p class="hint">当前没有联网补全来源可展示。</p>';
|
||
return;
|
||
}
|
||
|
||
var html = latestWebContext.map(function (lineRow) {
|
||
var lineNo = lineRow.line_no || '-';
|
||
var query = escapeHtml(lineRow.query || '');
|
||
var sources = Array.isArray(lineRow.sources) ? lineRow.sources : [];
|
||
var sourceHtml = sources.map(function (src) {
|
||
var title = escapeHtml(src.title || '未命名来源');
|
||
var snippet = escapeHtml(src.snippet || '');
|
||
var url = (src.url || '').trim();
|
||
var reliabilityLevel = (src.reliability_level || 'medium').toLowerCase();
|
||
var reliabilityLabel = escapeHtml(src.reliability_label || '中可信');
|
||
var reliabilityReason = escapeHtml(src.reliability_reason || '来源类型未知,建议人工确认');
|
||
var domain = escapeHtml(src.domain || '未知域名');
|
||
var link = url ? '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener">打开来源</a>' : '<span class="muted">无链接</span>';
|
||
return '<li>'
|
||
+ '<div class="source-head"><strong>' + title + '</strong><span class="source-badge source-' + reliabilityLevel + '">' + reliabilityLabel + '</span></div>'
|
||
+ '<p>' + snippet + '</p>'
|
||
+ '<p class="hint">依据: ' + reliabilityReason + '</p>'
|
||
+ '<p class="hint">域名: ' + domain + '</p>'
|
||
+ link
|
||
+ '</li>';
|
||
}).join('');
|
||
|
||
if (!sourceHtml) {
|
||
sourceHtml = '<li><span class="muted">无可用来源</span></li>';
|
||
}
|
||
|
||
return '<section class="panel">'
|
||
+ '<h3>第 ' + escapeHtml(lineNo) + ' 行</h3>'
|
||
+ '<p class="hint">检索词: ' + query + '</p>'
|
||
+ '<ul class="ai-source-items">' + sourceHtml + '</ul>'
|
||
+ '</section>';
|
||
}).join('');
|
||
|
||
sourcesBody.innerHTML = html;
|
||
}
|
||
|
||
function closeSourcesModal() {
|
||
if (!sourcesModal) {
|
||
return;
|
||
}
|
||
sourcesModal.hidden = true;
|
||
document.body.classList.remove('modal-open');
|
||
}
|
||
|
||
showSourcesBtn.addEventListener('click', function () {
|
||
if (!sourcesModal || showSourcesBtn.disabled) {
|
||
return;
|
||
}
|
||
sourcesModal.hidden = false;
|
||
document.body.classList.add('modal-open');
|
||
});
|
||
|
||
if (closeSourcesBtn) {
|
||
closeSourcesBtn.addEventListener('click', closeSourcesModal);
|
||
}
|
||
|
||
if (sourcesModal) {
|
||
sourcesModal.addEventListener('click', function (event) {
|
||
if (event.target === sourcesModal) {
|
||
closeSourcesModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
parseBtn.addEventListener('click', function () {
|
||
var lines = (textarea.value || '').trim();
|
||
if (!lines) {
|
||
status.textContent = '请先粘贴至少一行内容';
|
||
status.classList.remove('ok');
|
||
return;
|
||
}
|
||
|
||
parseBtn.disabled = true;
|
||
showSourcesBtn.disabled = true;
|
||
exportInvalidBtn.disabled = true;
|
||
status.textContent = '正在进行 AI 预处理...';
|
||
status.classList.remove('ok');
|
||
|
||
var payload = new URLSearchParams();
|
||
payload.set('lines', lines);
|
||
payload.set('mode', modeInput ? modeInput.value : 'box');
|
||
payload.set('use_web_search', webSearchInput && webSearchInput.checked ? '1' : '0');
|
||
|
||
fetch('{{ url_for('ai_inbound_parse') }}', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||
},
|
||
body: payload.toString()
|
||
}).then(function (resp) {
|
||
return resp.json().then(function (data) {
|
||
if (!resp.ok || !data.ok) {
|
||
throw new Error(data.message || '预处理失败');
|
||
}
|
||
return data;
|
||
});
|
||
}).then(function (data) {
|
||
renderRows(data.rows || []);
|
||
renderWebContext(data.web_context || []);
|
||
if (data.normalized_lines) {
|
||
textarea.value = data.normalized_lines;
|
||
}
|
||
|
||
var baseText = '预处理完成: 有效 ' + (data.valid_count || 0) + ' 行';
|
||
if (data.invalid_count) {
|
||
baseText += ',异常 ' + data.invalid_count + ' 行';
|
||
}
|
||
if (data.parse_notice) {
|
||
baseText += '。' + data.parse_notice;
|
||
}
|
||
|
||
status.textContent = baseText;
|
||
status.classList.add('ok');
|
||
}).catch(function (error) {
|
||
status.textContent = '预处理失败: ' + error.message;
|
||
status.classList.remove('ok');
|
||
}).finally(function () {
|
||
parseBtn.disabled = false;
|
||
});
|
||
});
|
||
|
||
exportInvalidBtn.addEventListener('click', function () {
|
||
var invalidRows = latestRows.filter(function (row) {
|
||
return !row.is_valid;
|
||
});
|
||
|
||
if (!invalidRows.length) {
|
||
status.textContent = '当前没有异常行可导出';
|
||
status.classList.remove('ok');
|
||
return;
|
||
}
|
||
|
||
var headers = ['line_no', 'raw', 'part_no', 'name', 'quantity', 'specification', 'note', 'errors', 'warnings'];
|
||
var rows = [headers];
|
||
|
||
invalidRows.forEach(function (row) {
|
||
rows.push([
|
||
row.line_no || '',
|
||
row.raw || '',
|
||
row.part_no || '',
|
||
row.name || '',
|
||
row.quantity || 0,
|
||
row.specification || '',
|
||
row.note || '',
|
||
(row.errors || []).join(' | '),
|
||
(row.warnings || []).join(' | ')
|
||
]);
|
||
});
|
||
|
||
function csvCell(value) {
|
||
var text = String(value == null ? '' : value);
|
||
if (/[",\n]/.test(text)) {
|
||
return '"' + text.replace(/"/g, '""') + '"';
|
||
}
|
||
return text;
|
||
}
|
||
|
||
var csv = rows.map(function (cols) {
|
||
return cols.map(csvCell).join(',');
|
||
}).join('\n');
|
||
var blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||
var url = URL.createObjectURL(blob);
|
||
var now = new Date();
|
||
var stamp = now.getFullYear()
|
||
+ String(now.getMonth() + 1).padStart(2, '0')
|
||
+ String(now.getDate()).padStart(2, '0')
|
||
+ '_'
|
||
+ String(now.getHours()).padStart(2, '0')
|
||
+ String(now.getMinutes()).padStart(2, '0')
|
||
+ String(now.getSeconds()).padStart(2, '0');
|
||
var link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = 'inbound_invalid_rows_' + stamp + '.csv';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
|
||
status.textContent = '已导出异常行 ' + invalidRows.length + ' 条';
|
||
status.classList.add('ok');
|
||
});
|
||
|
||
document.addEventListener('keydown', function (event) {
|
||
if (event.key === 'Escape') {
|
||
closeSourcesModal();
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|