Files
inventory/templates/box.html

528 lines
19 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
{% include '_account_menu.html' %}
</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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>