feat:增强模板的用户界面和功能

- 在 scanner.js 中为用户操作添加了 toast 通知。
- 更新 box.html 以包含额外的导航选项和改进的布局。
- 增强 edit.html,提供更清晰的说明和改进表单的可访问性。
- 修改了 error.html,以提供有关输入错误的用户指导。
- 改进了 index.html,以优化导航并添加了关键指标显示。
- 增强了 scan.html,优化了搜索输入和操作按钮。
- 引入了 stats.html,用于详细的库存统计和趋势。
- 创建了 types.html,用于分类概述库存类型。
This commit is contained in:
2026-03-10 01:34:02 +08:00
parent 859f92cdf0
commit 0a54bfd5aa
11 changed files with 2255 additions and 258 deletions

108
README.md
View File

@@ -7,6 +7,7 @@
- `28格小盒大盒`:常见 4 连排小盒(竖向 7 排)。 - `28格小盒大盒`:常见 4 连排小盒(竖向 7 排)。
- `14格中盒大盒`:中等盒子,无固定摆放图。 - `14格中盒大盒`:中等盒子,无固定摆放图。
- `袋装清单`:防静电袋列表模式(每行一个袋位,支持批量新增)。 - `袋装清单`:防静电袋列表模式(每行一个袋位,支持批量新增)。
- `袋装清单` 为固定容器(系统内置一个大盒),不需要新增/删除盒子。
v1.1 新增能力: v1.1 新增能力:
@@ -30,7 +31,8 @@ inventory/
│ ├── index.html │ ├── index.html
│ ├── box.html │ ├── box.html
│ ├── edit.html │ ├── edit.html
── scan.html ── scan.html
│ └── stats.html
└── static/ └── static/
├── css/ ├── css/
│ └── style.css │ └── style.css
@@ -66,16 +68,26 @@ python app.py
### 3.1 首页 `/` ### 3.1 首页 `/`
- 按容器类型显示 3 个列表 - 首页已改为入口跳转到 `分类总览` 页面
- 每个列表都可新增盒子。
- 每个盒子可在首页直接改名、修改前缀和起始序号、删除。 ### 3.1.1 分类总览 `/types`
- 每个盒子有概览按钮,快速查看已启用条目。
- 展示三类独立界面入口:`28格小盒大盒``14格中盒大盒``袋装清单`
- 每类入口显示当前容器数量,点击进入单独分类页面。
### 3.1.2 分类独立页 `/type/<box_type>`
- 每个分类使用独立页面,避免容器变多后的长页翻找。
- 页面内支持新增、设置、删除,并在操作后停留在当前分类页。
### 3.2 盒子详情 `/box/<box_id>` ### 3.2 盒子详情 `/box/<box_id>`
- `28格/14格`:格子视图,点格子进入编辑。 - `28格/14格`:格子视图,点格子进入编辑。
- `袋装清单`:表格视图,支持单条新增和批量新增。 - `袋装清单`:表格视图,支持单条新增和批量新增。
- 页面显示自动编号范围(由前缀+起始序号生成) - `袋装清单` 仅使用编号前缀(如 `BAG`),不设置编号范围
- `28格/14格` 支持快速入库:多行粘贴后自动分配空位。
- 支持按当前盒子导出打标 CSV仅导出启用记录可用于热敏打标机导入。
- 打标 CSV 列名为中英双语格式(如 `料号(part_no)``打标文本(label_text)`),便于直接识别。
### 3.3 编辑页 `/edit/<box_id>/<slot>` ### 3.3 编辑页 `/edit/<box_id>/<slot>`
@@ -88,6 +100,18 @@ python app.py
- 可按料号或名称搜索。 - 可按料号或名称搜索。
- 支持扫码枪输入后回车触发搜索。 - 支持扫码枪输入后回车触发搜索。
### 3.5 统计页 `/stats`
- 独立统计页,仅展示核心指标:`库存总量 / 分类占比 / 变动趋势`
- 支持 `7天``30天` 视图切换:`/stats?days=7``/stats?days=30`
- 支持分类筛选:`/stats?days=30&box_type=small_28`(可选值:`small_28``medium_14``bag``all`)。
- 趋势图基于库存变动日志实时计算,来源包括:新增、快速入库、启用/停用、删除。
- 说明:升级前的历史操作不会自动回溯写入日志,趋势从启用该版本后开始逐步真实化。
- 新增最近操作时间线(最新 20 条),便于追踪库存变化来源。
- 筛选到单一分类后,指标会切换为更有意义的数据:`分类库存占比 / 周期操作次数 / 活跃天数 / 分类内元件Top`
- 支持趋势数据导出 CSV`/stats/export?days=7&box_type=all`(包含 `daily_delta` 日增减列)。
- 支持清除统计日志(当前筛选或全部),仅影响统计与趋势,不影响库存数据本体。
## 4. 袋装批量新增格式 ## 4. 袋装批量新增格式
在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔: 在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔:
@@ -109,7 +133,21 @@ python app.py
- `数量` 需为大于等于 0 的整数(留空按 0 - `数量` 需为大于等于 0 的整数(留空按 0
- 无效行会跳过并提示。 - 无效行会跳过并提示。
## 5. 自动编号规则(新增 ## 5. 快速入库28格/14格
在盒子页面使用“快速入库”,每行一条:
```text
料号, 名称, 数量, 规格, 位置备注, 备注
```
规则:
- 自动放入当前盒子的空位。
- 同料号已存在时自动累加数量(不重复占位)。
- 格子满了会跳过并提示具体行号。
## 6. 自动编号规则(新增)
新增盒子时只需填写: 新增盒子时只需填写:
@@ -131,7 +169,7 @@ python app.py
- 基础名称 `电阻盒` + 范围 `A1-A28` -> `电阻盒 A1-A28` - 基础名称 `电阻盒` + 范围 `A1-A28` -> `电阻盒 A1-A28`
- 若发生重名会自动变为:`电阻盒 A1-A28 #2` - 若发生重名会自动变为:`电阻盒 A1-A28 #2`
## 6. 元器件命名建议(简洁版) ## 7. 元器件命名建议(简洁版)
为避免命名过长又保证可检索,建议: 为避免命名过长又保证可检索,建议:
@@ -151,13 +189,57 @@ python app.py
规格: 50V X7R 规格: 50V X7R
``` ```
## 7. 数据库说明 ### 7.1 轻量入库规范(推荐)
目标:字段尽量少,但保证后续能搜索、能区分、能补货。
最少必填3项
- `料号(part_no)`:优先填厂家型号(如 `STM32F103C8T6`)。
- `名称(name)``品类 + 型号/关键值`(如 `MCU STM32F103C8T6`)。
- `数量(quantity)`:当前实际库存数量。
建议选填2-3项
- `规格(specification)`:只写 2-4 个关键参数(如 `Cortex-M3 / 64KB Flash / LQFP-48`)。
- `位置备注(location)`:盒位或袋位(如 `A12``BAG4`)。
- `备注(note)`:来源或追溯信息(如 `LCSC item 9243` 或商品链接)。
不建议录入(避免复杂且易过期):
- 实时单价、促销价、交期、商城库存等动态信息。
推荐录入模板:
```text
料号: <厂家型号>
名称: <品类 + 型号/关键值>
规格: <封装 + 关键参数>
数量: <整数>
位置备注: <盒位/袋位>
备注: <来源编号或链接>
```
示例:
```text
料号: STM32F103C8T6
名称: MCU STM32F103C8T6
规格: Cortex-M3 / 64KB Flash / LQFP-48
数量: 10
位置备注: BAG4
备注: LCSC item 9243
```
## 8. 数据库说明
- 使用 SQLite文件路径`data/inventory.db` - 使用 SQLite文件路径`data/inventory.db`
- 库存变动日志表:`inventory_events`(用于统计页趋势计算)
- `inventory_events` 主要字段:`box_type``part_no``event_type``delta``created_at`
- 首次发布执行一次 `python init_db.py` - 首次发布执行一次 `python init_db.py`
- 后续通常不需要重复初始化 - 后续通常不需要重复初始化
## 8. 服务器部署(宝塔) ## 9. 服务器部署(宝塔)
建议流程: 建议流程:
@@ -171,7 +253,7 @@ python app.py
建议 Gunicorn 仅监听内网:`127.0.0.1:5000` 建议 Gunicorn 仅监听内网:`127.0.0.1:5000`
## 9. 日常发布流程 ## 10. 日常发布流程
本地开发后: 本地开发后:
@@ -192,7 +274,7 @@ pip install -r requirements.txt
最后在宝塔里重启 Python 项目。 最后在宝塔里重启 Python 项目。
## 10. 备份建议 ## 11. 备份建议
重点备份:`data/inventory.db` 重点备份:`data/inventory.db`
@@ -202,7 +284,7 @@ pip install -r requirements.txt
cp /www/wwwroot/inventory/data/inventory.db /www/backup/inventory_$(date +%F).db cp /www/wwwroot/inventory/data/inventory.db /www/backup/inventory_$(date +%F).db
``` ```
## 11. 常见问题 ## 12. 常见问题
### Q1: VS Code 提示无法解析 `flask` 导入 ### Q1: VS Code 提示无法解析 `flask` 导入

908
app.py

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,24 @@
const input = document.getElementById("scan-input"); const input = document.getElementById("scan-input");
const form = document.getElementById("scan-search-form"); const form = document.getElementById("scan-search-form");
function showToast(message) {
let stack = document.querySelector(".toast-stack");
if (!stack) {
stack = document.createElement("div");
stack.className = "toast-stack";
document.body.appendChild(stack);
}
const toast = document.createElement("div");
toast.className = "toast";
toast.textContent = message;
stack.appendChild(toast);
setTimeout(function () {
toast.remove();
}, 1600);
}
if (!input || !form) { if (!input || !form) {
return; return;
} }
@@ -15,6 +33,13 @@
if (event.key === "Escape") { if (event.key === "Escape") {
input.value = ""; input.value = "";
input.focus(); input.focus();
showToast("已清空搜索词");
}
});
form.addEventListener("submit", function () {
if (input.value.trim()) {
showToast("正在搜索...");
} }
}); });
})(); })();

View File

@@ -8,9 +8,14 @@
</head> </head>
<body> <body>
<header class="hero slim"> <header class="hero slim">
<h1>{{ box.name }} - {{ box_types.get(box.box_type, box_types['small_28']).label }}</h1> <div>
<nav> <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> <a class="btn btn-light" href="{{ url_for('index') }}">返回首页</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>
<a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a> <a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a>
</nav> </nav>
</header> </header>
@@ -23,10 +28,13 @@
<p class="notice">{{ notice }}</p> <p class="notice">{{ notice }}</p>
{% endif %} {% endif %}
<div class="entry-shell">
<section class="entry-main">
{% if box.box_type == 'bag' %} {% if box.box_type == 'bag' %}
<section class="panel"> <section class="panel">
<h2>袋装记录</h2> <h2>袋装记录</h2>
<p class="group-desc">编号范围: {{ slot_range }} | 每行一个袋位</p> <p class="group-desc">编号前缀: {{ box.slot_prefix }} | 一袋一种器件(同料号会自动合并)</p>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@@ -37,7 +45,6 @@
<th>数量</th> <th>数量</th>
<th>状态</th> <th>状态</th>
<th>规格</th> <th>规格</th>
<th>位置备注</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
@@ -51,12 +58,11 @@
<td>{{ c.quantity }}</td> <td>{{ c.quantity }}</td>
<td>{% if c.is_enabled %}启用{% else %}停用{% endif %}</td> <td>{% if c.is_enabled %}启用{% else %}停用{% endif %}</td>
<td>{{ c.specification or '-' }}</td> <td>{{ c.specification or '-' }}</td>
<td>{{ c.location or '-' }}</td>
<td><a href="{{ url_for('edit_component', box_id=box.id, slot=c.slot_index) }}">编辑</a></td> <td><a href="{{ url_for('edit_component', box_id=box.id, slot=c.slot_index) }}">编辑</a></td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="7">当前没有袋装记录,请先新增。</td> <td colspan="6">当前没有袋装记录,请先新增。</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -66,14 +72,15 @@
<section class="panel"> <section class="panel">
<h2>新增单条</h2> <h2>新增单条</h2>
<p class="hint">3步完成: 填写料号与名称 -> 填数量 -> 保存到袋装清单(同料号自动合并)</p>
<form class="form-grid" method="post" action="{{ url_for('add_bag_item', box_id=box.id) }}"> <form class="form-grid" method="post" action="{{ url_for('add_bag_item', box_id=box.id) }}">
<label> <label>
料号 * 料号 *
<input type="text" name="part_no" required> <input type="text" name="part_no" required placeholder="如 STM32F103C8T6">
</label> </label>
<label> <label>
名称 * 名称 *
<input type="text" name="name" required> <input type="text" name="name" required placeholder="如 MCU STM32F103C8T6">
</label> </label>
<label> <label>
数量 数量
@@ -81,15 +88,11 @@
</label> </label>
<label> <label>
规格 规格
<input type="text" name="specification"> <input type="text" name="specification" placeholder="如 Cortex-M3 / LQFP-48">
</label>
<label>
位置备注
<input type="text" name="location">
</label> </label>
<label class="full"> <label class="full">
备注 备注
<input type="text" name="note"> <input type="text" name="note" placeholder="如 LCSC item 9243">
</label> </label>
<div class="actions full"> <div class="actions full">
<button class="btn" type="submit">新增记录</button> <button class="btn" type="submit">新增记录</button>
@@ -99,9 +102,10 @@
<section class="panel"> <section class="panel">
<h2>批量新增</h2> <h2>批量新增</h2>
<p class="hint">每行一条, 格式: 料号, 名称, 数量, 规格, 位置备注, 备注。可用英文逗号或 Tab 分隔。</p> <p class="hint">每行一条, 格式: 料号, 名称, 数量, 规格, 备注。可用英文逗号或 Tab 分隔;同料号自动合并</p>
<form method="post" action="{{ url_for('add_bag_items_batch', box_id=box.id) }}"> <form method="post" action="{{ url_for('add_bag_items_batch', box_id=box.id) }}">
<textarea class="batch-input" name="lines" rows="8" placeholder="10K-0603, 贴片电阻10K, 500, 0603, A袋, 常用\n100nF-0603, 电容100nF, 300, 0603, B袋, X7R"></textarea> <textarea class="batch-input" name="lines" rows="8" placeholder="10K-0603, 贴片电阻10K, 500, 0603, 常用\n100nF-0603, 电容100nF, 300, 0603, X7R"></textarea>
<p class="hint">建议格式: 名称尽量写品类+型号;规格只留关键参数。</p>
<div class="actions"> <div class="actions">
<button class="btn" type="submit">批量导入</button> <button class="btn" type="submit">批量导入</button>
</div> </div>
@@ -112,24 +116,104 @@
<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.slot_capacity <= 4 %} slot-grid-bag{% endif %}"> <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.slot_capacity <= 4 %} slot-grid-bag{% endif %}">
{% for item in slots %} {% 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) }}"> <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) }}">
{% if box.box_type in ['small_28', 'medium_14'] %}
<span class="slot-no">位置 {{ '%02d'|format(item.slot) }}</span>
{% else %}
<span class="slot-no">{{ item.slot_code }}</span> <span class="slot-no">{{ item.slot_code }}</span>
{% endif %}
{% if item.component %} {% if item.component %}
<small>{{ item.component.name }}</small> <small class="slot-name" title="{{ item.component.name }}"><span class="slot-name-text">{{ item.component.name }}</span></small>
<small>数量: {{ item.component.quantity }}</small> <small class="slot-meta">数量: {{ item.component.quantity }}</small>
{% if item.component.quantity < low_stock_threshold %} {% if item.component.quantity < low_stock_threshold %}
<small>低库存预警</small> <small class="slot-alert">低库存预警</small>
{% endif %} {% endif %}
{% else %} {% else %}
<small>空位</small> <small class="slot-meta">空位</small>
{% endif %} {% endif %}
</a> </a>
{% endfor %} {% endfor %}
</section> </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分隔同料号会自动累加数量。</p>
<form method="post" action="{{ url_for('quick_inbound', box_id=box.id) }}">
<textarea class="batch-input" name="lines" rows="8" placeholder="10K-0603, 电阻10K 0603, 500, 1%, 常用\n100nF-0603, 电容100nF 0603, 300, 50V X7R, 去耦"></textarea>
<p class="hint">建议: part_no 用厂家型号name 用品类+型号specification 只写关键参数。</p>
<div class="actions">
<button class="btn" type="submit">批量快速入库</button>
</div>
</form>
</div>
</div>
{% endif %} {% endif %}
</section>
<aside class="entry-sidebar">
{% if box.box_type != 'bag' %}
<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>
{% endif %}
<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> </main>
<script>
(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();
}
});
})();
</script>
</body> </body>
</html> </html>

View File

@@ -8,8 +8,14 @@
</head> </head>
<body> <body>
<header class="hero slim"> <header class="hero slim">
<h1>{{ box.name }} - 编号 {{ slot_code }}</h1> <div>
<a class="btn btn-light" href="{{ url_for('view_box', box_id=box.id) }}">返回宫格</a> <h1>{{ box.name }} - 编号 {{ slot_code }}</h1>
<p>步骤: 填写核心字段 -> 检查数量 -> 保存</p>
</div>
<div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
<a class="btn btn-light" href="{{ url_for('view_box', box_id=box.id) }}">返回宫格</a>
</div>
</header> </header>
<main class="container"> <main class="container">
@@ -17,48 +23,61 @@
<p class="alert">{{ error }}</p> <p class="alert">{{ error }}</p>
{% endif %} {% endif %}
<form class="panel form-grid" method="post"> <div class="entry-shell">
<label> <section class="entry-main">
料号 * <form class="panel form-grid" method="post">
<input type="text" name="part_no" required value="{{ component.part_no if component else '' }}"> <label>
</label> 料号 *
<label> <input type="text" name="part_no" required value="{{ component.part_no if component else '' }}" aria-label="料号" placeholder="如 STM32F103C8T6">
名称 * </label>
<input type="text" name="name" required value="{{ component.name if component else '' }}"> <label>
</label> 名称 *
<label> <input type="text" name="name" required value="{{ component.name if component else '' }}" aria-label="名称" placeholder="如 MCU STM32F103C8T6">
规格 </label>
<input type="text" name="specification" value="{{ component.specification if component else '' }}"> <label>
</label> 规格
<label> <input type="text" name="specification" value="{{ component.specification if component else '' }}" placeholder="如 Cortex-M3 / LQFP-48">
数量 </label>
<input type="number" name="quantity" min="0" value="{{ component.quantity if component else 0 }}"> <label>
</label> 数量
<label> <input type="number" name="quantity" min="0" value="{{ component.quantity if component else 0 }}">
位置备注 </label>
<input type="text" name="location" value="{{ component.location if component else '' }}"> <label class="full">
</label> 备注
<label class="full"> <textarea name="note" rows="3" placeholder="如 LCSC item 9243">{{ component.note if component else '' }}</textarea>
<input type="checkbox" name="is_enabled" value="1" {% if component is none or component.is_enabled %}checked{% endif %}> </label>
启用该位
</label>
<label class="full">
备注
<textarea name="note" rows="3">{{ component.note if component else '' }}</textarea>
</label>
<div class="actions full"> <div class="actions full">
<button class="btn" type="submit" name="action" value="save">保存</button> <button class="btn" type="submit" name="action" value="save">保存</button>
{% if component %} {% if component %}
{% if component.is_enabled %} {% if component.is_enabled %}
<button class="btn btn-light" type="submit" name="action" value="toggle_disable">停用</button> <button class="btn btn-light" type="submit" name="action" value="toggle_disable">停用</button>
{% else %} {% else %}
<button class="btn btn-light" type="submit" name="action" value="toggle_enable">启用</button> <button class="btn btn-light" type="submit" name="action" value="toggle_enable">启用</button>
{% endif %} {% endif %}
<button class="btn btn-danger" type="submit" name="action" value="delete" onclick="return confirm('确认清空该格子吗?')">删除</button> <button class="btn btn-danger" type="submit" name="action" value="delete" onclick="return confirm('确认删除这个元件记录吗?')">删除</button>
{% endif %} {% endif %}
</div> </div>
</form> </form>
</section>
<aside class="entry-sidebar">
<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> </main>
</body> </body>
</html> </html>

View File

@@ -8,7 +8,10 @@
</head> </head>
<body> <body>
<header class="hero slim"> <header class="hero slim">
<h1>{{ status_code }} - {{ title }}</h1> <div>
<h1>{{ status_code }} - {{ title }}</h1>
<p>请检查输入参数后重试</p>
</div>
<a class="btn btn-light" href="{{ url_for('index') }}">回到首页</a> <a class="btn btn-light" href="{{ url_for('index') }}">回到首页</a>
</header> </header>

View File

@@ -8,34 +8,106 @@
</head> </head>
<body> <body>
<header class="hero"> <header class="hero">
<h1>电子元件库存管理 v1.0</h1> <div>
<a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a> <h1>{% if separate_mode %}{{ box_types[current_box_type].label }}{% else %}库存管理{% endif %}</h1>
<p>{% if separate_mode %}当前为独立分类界面,减少长列表翻找成本{% else %}极简中性灰布局,聚焦数量/分类/变动核心信息{% endif %}</p>
</div>
<div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('types_page') }}">分类总览</a>
<a class="btn btn-light" href="#quick-add">新增库存</a>
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
<a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a>
</div>
</header> </header>
<main class="container"> <main class="container">
<div class="layout-shell"> <div class="layout-shell">
<aside class="catalog-sidebar panel"> <aside class="catalog-sidebar">
<h2>容器导航</h2> <button class="sidebar-toggle btn btn-light" type="button" aria-expanded="false" aria-controls="side-low-stock">低库存面板</button>
<nav class="catalog-nav"> <section class="panel" id="sidebar-nav-panel">
<h2>容器导航</h2>
<div class="card-actions icon-links" aria-label="快捷功能">
<a class="icon-link" href="#sidebar-nav-panel" title="定位容器导航" aria-label="定位容器导航">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="2"/><path d="M4 10h16"/><path d="M12 10v8"/></svg>
</a>
<a class="icon-link" href="{{ url_for('stats_page') }}" title="打开统计页" aria-label="打开统计页">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 18h16"/><path d="M6 14l4-4 4 2 4-5"/></svg>
</a>
<a class="icon-link" href="#quick-add" title="跳转新增库存" aria-label="跳转新增库存">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 8v8"/><path d="M8 12h8"/></svg>
</a>
<a class="icon-link" href="#side-low-stock" title="跳转低库存面板" aria-label="跳转低库存面板">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="6" height="6" rx="2"/><rect x="13" y="5" width="6" height="6" rx="2"/><rect x="5" y="13" width="6" height="6" rx="2"/><rect x="13" y="13" width="6" height="6" rx="2"/></svg>
</a>
</div>
<nav class="catalog-nav" id="catalog-nav-links">
{% for key, meta in box_types.items() %} {% for key, meta in box_types.items() %}
<a href="#group-{{ key }}">{{ meta.label }} ({{ groups[key]|length }})</a> <a href="{{ url_for('type_page', box_type=key) }}" class="{% if separate_mode and current_box_type == key %}active{% endif %}">{{ meta.label }} ({{ groups[key]|length }})</a>
{% endfor %} {% endfor %}
</nav> </nav>
</section>
<section class="panel side-metrics">
<h2>关键指标</h2>
<div class="side-metrics-grid">
<article class="metric-card">
<p class="metric-title">容器总数</p>
<p class="metric-value">{{ stats.box_count }}</p>
</article>
<article class="metric-card">
<p class="metric-title">启用元件</p>
<p class="metric-value">{{ stats.active_items }}</p>
</article>
<article class="metric-card">
<p class="metric-title">近7天净变动</p>
<p class="metric-value">{% if stats.period_net_change_7d > 0 %}+{% endif %}{{ stats.period_net_change_7d }}</p>
</article>
<article class="metric-card">
<p class="metric-title">待补货元件</p>
<p class="metric-value">{{ stats.low_stock_count }}</p>
</article>
</div>
</section>
<section class="panel side-low-stock" id="side-low-stock">
<h2>低库存元器件</h2>
<ul class="side-low-stock-list">
{% for item in low_stock_items %}
<li>
<div>
<strong>{{ item.name }}</strong>
<p class="hint">{{ item.part_no }} | {{ item.box_name }} / {{ item.slot_code }} | 数量 {{ item.quantity }}</p>
</div>
<a class="btn btn-light" href="{{ item.edit_url }}">编辑</a>
</li>
{% else %}
<li class="muted">当前没有低库存元器件。</li>
{% endfor %}
</ul>
</section>
</aside> </aside>
<section class="catalog-content"> <section class="catalog-content">
<h2>容器列表</h2> <h2>容器列表</h2>
{% for key, meta in box_types.items() %} {% if view_box_types is defined %}
{% set display_box_types = view_box_types %}
{% else %}
{% set display_box_types = box_types.keys() %}
{% endif %}
{% for key in display_box_types %}
{% set meta = box_types[key] %}
<section class="group-panel panel" id="group-{{ key }}"> <section class="group-panel panel" id="group-{{ key }}">
<div class="group-title-row"> <div class="group-title-row">
<h3>{{ meta.label }}</h3> <h3>{{ meta.label }}</h3>
<span class="group-desc">{{ meta.default_desc }}</span> <span class="group-desc">{{ meta.default_desc }}</span>
</div> </div>
<form class="new-box-form" method="post" action="{{ url_for('create_box') }}"> {% if key != 'bag' %}
<form class="new-box-form" method="post" action="{{ url_for('create_box') }}" {% if loop.first %}id="quick-add"{% endif %}>
<input type="hidden" name="box_type" value="{{ key }}"> <input type="hidden" name="box_type" value="{{ key }}">
<input type="text" name="name" placeholder="基础名称(自动拼范围)" required> {% if separate_mode %}<input type="hidden" name="return_to_type" value="{{ current_box_type }}">{% endif %}
<input type="text" name="name" placeholder="基础名称(自动拼范围)" required aria-label="基础名称">
<input type="text" name="slot_prefix" placeholder="前缀(如A/B/C)"> <input type="text" name="slot_prefix" placeholder="前缀(如A/B/C)">
<input type="number" name="start_number" min="0" value="1" placeholder="起始序号"> <input type="number" name="start_number" min="0" value="1" placeholder="起始序号">
<input type="text" name="description" placeholder="备注(可选)"> <input type="text" name="description" placeholder="备注(可选)">
@@ -43,20 +115,31 @@
<button class="btn" type="submit">新增盒子</button> <button class="btn" type="submit">新增盒子</button>
<span class="hint suggest-preview"></span> <span class="hint suggest-preview"></span>
</form> </form>
{% else %}
<p class="hint">袋装清单为固定容器(大盒),不需要新增盒子。</p>
{% endif %}
<section class="box-list"> <section class="box-list">
{% for item in groups[key] %} {% for item in groups[key] %}
<article class="box-card"> <article class="box-card">
<h4>{{ item.box.name }}</h4> <h4>{{ item.box.name }}</h4>
<p>{{ item.box.description or '暂无描述' }}</p> <p>{{ item.box.description or '暂无描述' }}</p>
{% if item.box.box_type == 'bag' %}
<p>编号前缀: {{ item.box.slot_prefix }} | 袋装清单不使用范围</p>
<p>已记录: {{ item.used_count }} 项</p>
{% else %}
<p>编号前缀: {{ item.box.slot_prefix }} | 范围: {{ item.slot_range }}</p> <p>编号前缀: {{ item.box.slot_prefix }} | 范围: {{ item.slot_range }}</p>
<p>已启用: {{ item.used_count }}/{{ item.box.slot_capacity }}</p> <p>已启用: {{ item.used_count }}/{{ item.box.slot_capacity }}</p>
{% endif %}
<div class="card-actions"> <div class="card-actions">
<a class="btn" href="{{ url_for('view_box', box_id=item.box.id) }}">进入列表</a> <a class="btn" href="{{ url_for('view_box', box_id=item.box.id) }}">进入列表</a>
<form method="post" action="{{ url_for('delete_box', box_id=item.box.id) }}" onsubmit="return confirm('确认删除盒子及内部全部记录吗?')"> {% if item.box.box_type != 'bag' %}
<form method="post" action="{{ url_for('delete_box', box_id=item.box.id) }}" onsubmit="return confirm('确认删除这个盒子及其内部记录吗?')">
{% if separate_mode %}<input type="hidden" name="return_to_type" value="{{ current_box_type }}">{% endif %}
<button class="btn btn-danger" type="submit">删除</button> <button class="btn btn-danger" type="submit">删除</button>
</form> </form>
{% endif %}
</div> </div>
<details class="box-overview"> <details class="box-overview">
@@ -76,6 +159,7 @@
<summary>设置(改名/前缀/起始号)</summary> <summary>设置(改名/前缀/起始号)</summary>
<p class="hint">输入基础名称后,系统会自动生成: 基础名称 + 编号范围。</p> <p class="hint">输入基础名称后,系统会自动生成: 基础名称 + 编号范围。</p>
<form class="new-box-form compact" method="post" action="{{ url_for('update_box', box_id=item.box.id) }}"> <form class="new-box-form compact" method="post" action="{{ url_for('update_box', box_id=item.box.id) }}">
{% if separate_mode %}<input type="hidden" name="return_to_type" value="{{ current_box_type }}">{% endif %}
<input type="text" name="name" value="{{ item.base_name }}" required> <input type="text" name="name" value="{{ item.base_name }}" required>
<input type="text" name="slot_prefix" value="{{ item.box.slot_prefix }}" required> <input type="text" name="slot_prefix" value="{{ item.box.slot_prefix }}" required>
<input type="number" name="start_number" min="0" value="{{ item.box.start_number }}" required> <input type="number" name="start_number" min="0" value="{{ item.box.start_number }}" required>
@@ -98,6 +182,69 @@
<script> <script>
(function () { (function () {
function bindSidebarCompactMode() {
var sidebar = document.querySelector('.catalog-sidebar');
var toggleBtn = document.querySelector('.sidebar-toggle');
if (!sidebar) {
return;
}
var storageKey = 'inventorySidebarManualExpand';
var manualExpand = false;
try {
manualExpand = localStorage.getItem(storageKey) === '1';
} catch (e) {
manualExpand = false;
}
function updateToggleState() {
if (!toggleBtn) {
return;
}
toggleBtn.setAttribute('aria-expanded', manualExpand ? 'true' : 'false');
toggleBtn.textContent = manualExpand ? '收起低库存面板' : '展开低库存面板';
}
function syncCompactState() {
var shouldCompact = window.innerWidth > 980 && window.scrollY > 220;
sidebar.classList.toggle('compact', shouldCompact);
sidebar.classList.toggle('manual-expand', shouldCompact && manualExpand);
updateToggleState();
}
if (toggleBtn) {
toggleBtn.addEventListener('click', function () {
manualExpand = !manualExpand;
try {
localStorage.setItem(storageKey, manualExpand ? '1' : '0');
} catch (e) {
// ignore storage errors
}
syncCompactState();
});
}
window.addEventListener('scroll', syncCompactState, { passive: true });
window.addEventListener('resize', syncCompactState);
syncCompactState();
}
function showToast(message) {
var stack = document.querySelector('.toast-stack');
if (!stack) {
stack = document.createElement('div');
stack.className = 'toast-stack';
document.body.appendChild(stack);
}
var toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
stack.appendChild(toast);
setTimeout(function () {
toast.remove();
}, 1600);
}
function updateSuggest(form, payload) { function updateSuggest(form, payload) {
var startInput = form.querySelector('input[name="start_number"]'); var startInput = form.querySelector('input[name="start_number"]');
var prefixInput = form.querySelector('input[name="slot_prefix"]'); var prefixInput = form.querySelector('input[name="slot_prefix"]');
@@ -112,6 +259,7 @@
if (preview) { if (preview) {
preview.textContent = '建议范围: ' + payload.preview_range; preview.textContent = '建议范围: ' + payload.preview_range;
} }
showToast('已更新建议起始号');
} }
document.querySelectorAll('.suggest-start-btn').forEach(function (btn) { document.querySelectorAll('.suggest-start-btn').forEach(function (btn) {
@@ -139,16 +287,18 @@
.then(function (resp) { return resp.json(); }) .then(function (resp) { return resp.json(); })
.then(function (data) { .then(function (data) {
if (!data.ok) { if (!data.ok) {
alert(data.message || '建议起始号失败'); showToast(data.message || '建议起始号失败');
return; return;
} }
updateSuggest(form, data); updateSuggest(form, data);
}) })
.catch(function () { .catch(function () {
alert('建议起始号失败,请稍后重试'); showToast('建议起始号失败,请稍后重试');
}); });
}); });
}); });
bindSidebarCompactMode();
})(); })();
</script> </script>
</body> </body>

View File

@@ -8,14 +8,23 @@
</head> </head>
<body> <body>
<header class="hero slim"> <header class="hero slim">
<h1>扫码/搜索元件</h1> <div>
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a> <h1>扫码 / 搜索</h1>
<p>一步输入,直达元件位置与库存状态</p>
</div>
<div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
</div>
</header> </header>
<main class="container"> <main class="container">
<section class="panel"> <section class="panel">
<div class="card-actions" aria-hidden="true">
<svg class="icon" viewBox="0 0 24 24"><path d="M4 7V4h3"/><path d="M20 7V4h-3"/><path d="M4 17v3h3"/><path d="M20 17v3h-3"/><path d="M8 12h8"/></svg>
</div>
<form method="get" action="{{ url_for('scan_page') }}" class="search-row" id="scan-search-form"> <form method="get" action="{{ url_for('scan_page') }}" class="search-row" id="scan-search-form">
<input id="scan-input" type="text" name="q" placeholder="输入或扫码料号/名称" value="{{ keyword }}"> <input id="scan-input" type="search" name="q" placeholder="输入或扫码料号/名称" value="{{ keyword }}" aria-label="搜索关键字">
<button class="btn" type="submit">搜索</button> <button class="btn" type="submit">搜索</button>
</form> </form>
<p class="hint">扫码枪通常会自动输入后回车,可直接触发搜索。</p> <p class="hint">扫码枪通常会自动输入后回车,可直接触发搜索。</p>
@@ -45,7 +54,7 @@
<td>{{ c.quantity }}</td> <td>{{ c.quantity }}</td>
<td>{{ row.box_name }} / {{ row.slot_code }}</td> <td>{{ row.box_name }} / {{ row.slot_code }}</td>
<td>{% if c.is_enabled %}启用{% else %}停用{% endif %}</td> <td>{% if c.is_enabled %}启用{% else %}停用{% endif %}</td>
<td><a href="{{ url_for('edit_component', box_id=c.box_id, slot=c.slot_index) }}">编辑</a></td> <td><a class="btn btn-light" href="{{ url_for('edit_component', box_id=c.box_id, slot=c.slot_index) }}">编辑</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

181
templates/stats.html Normal file
View File

@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>库存统计</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<header class="hero slim">
<div>
<h1>库存统计</h1>
<p>仅展示核心指标: 数量、分类、变动</p>
</div>
<nav class="hero-actions">
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
<a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a>
</nav>
</header>
<main class="container">
{% if notice %}
<p class="notice">{{ notice }}</p>
{% endif %}
<section class="panel stats-filters">
<form class="stats-filter-form" method="get" action="{{ url_for('stats_page') }}">
<label for="box_type" class="muted">分类筛选</label>
<select id="box_type" name="box_type">
<option value="all" {% if box_type_filter == 'all' %}selected{% endif %}>全部分类</option>
{% for key, meta in box_types.items() %}
<option value="{{ key }}" {% if box_type_filter == key %}selected{% endif %}>{{ meta.label }}</option>
{% endfor %}
</select>
<input type="hidden" name="days" value="{{ days }}">
<button class="btn" type="submit">应用筛选</button>
<a class="btn btn-light" href="{{ url_for('stats_export_csv', days=days, box_type=box_type_filter) }}">导出CSV</a>
</form>
<div class="card-actions">
<form method="post" action="{{ url_for('clear_stats_logs') }}" onsubmit="return confirm('确认清除当前筛选的统计日志吗?不会删除库存数据。');">
<input type="hidden" name="days" value="{{ days }}">
<input type="hidden" name="box_type" value="{{ box_type_filter }}">
<input type="hidden" name="scope" value="current">
<button class="btn btn-light" type="submit">清除当前筛选统计</button>
</form>
<form method="post" action="{{ url_for('clear_stats_logs') }}" onsubmit="return confirm('确认清除全部统计日志吗?不会删除库存数据。');">
<input type="hidden" name="days" value="{{ days }}">
<input type="hidden" name="box_type" value="all">
<input type="hidden" name="scope" value="all">
<button class="btn btn-danger" type="submit">清除全部统计</button>
</form>
</div>
</section>
<section class="metrics-grid">
<article class="metric-card">
<p class="metric-title">{% if box_type_filter == 'all' %}库存总量{% else %}分类库存量{% endif %}</p>
<p class="metric-value">{{ total_quantity }}</p>
{% if box_type_filter != 'all' %}
<p class="hint">占总库存 {{ inventory_share }}%</p>
{% endif %}
</article>
<article class="metric-card">
<p class="metric-title">周期操作次数</p>
<p class="metric-value">{{ period_operation_count }}</p>
</article>
<article class="metric-card">
<p class="metric-title">活跃天数</p>
<p class="metric-value">{{ active_days }}/{{ days }}</p>
</article>
<article class="metric-card">
<p class="metric-title">{{ days }}天净变动</p>
<p class="metric-value">{% if period_net_change > 0 %}+{% endif %}{{ period_net_change }}</p>
</article>
</section>
<section class="panel stats-tabs">
<a class="btn {% if days != 7 %}btn-light{% endif %}" href="{{ url_for('stats_page', days=7, box_type=box_type_filter) }}">近7天</a>
<a class="btn {% if days != 30 %}btn-light{% endif %}" href="{{ url_for('stats_page', days=30, box_type=box_type_filter) }}">近30天</a>
<span class="hint">趋势基于库存变动日志实时计算,包含新增、快速入库、启停、删除的数量变化。</span>
</section>
<section class="stats-layout">
<article class="panel trend-panel">
<h2>库存变动趋势</h2>
<div class="trend-chart" role="img" aria-label="库存变动折线图">
<svg viewBox="0 0 520 180" preserveAspectRatio="none">
<polyline points="{{ trend_polyline }}" />
</svg>
<div class="trend-axis">
{% if trend_points %}
<span>{{ trend_points[0].label }}</span>
<span>{{ trend_points[-1].label }}</span>
{% endif %}
</div>
</div>
<p class="hint">区间最小值 {{ min_value }} | 区间最大值 {{ max_value }}</p>
</article>
<article class="panel">
<h2>{{ chart_title }}</h2>
<div class="chart-list">
{% for item in chart_rows %}
{% set width = 0 %}
{% if max_chart_quantity > 0 %}
{% set width = (item.quantity * 100 / max_chart_quantity)|round(0, 'floor') %}
{% endif %}
<div class="chart-row">
<span>{{ item.label }}</span>
<div class="bar-track" role="img" aria-label="{{ item.label }}库存占比">
<div class="bar-fill" data-width="{{ width }}"></div>
</div>
<span>{{ item.quantity }}</span>
</div>
{% endfor %}
</div>
</article>
</section>
<section class="panel">
<h2>分类趋势快照</h2>
<div class="mini-trend-grid">
{% for row in box_type_series %}
<article class="mini-trend-card">
<p class="metric-title">{{ row.label }}</p>
<svg viewBox="0 0 220 56" preserveAspectRatio="none" class="mini-trend-svg" role="img" aria-label="{{ row.label }}趋势图">
<polyline points="{{ row.sparkline }}"></polyline>
</svg>
<p class="hint">当前 {{ row.latest }} | 净变动 {% if row.delta > 0 %}+{% endif %}{{ row.delta }}</p>
</article>
{% endfor %}
</div>
</section>
<section class="panel">
<h2>最近操作</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>分类</th>
<th>料号</th>
<th>变动</th>
</tr>
</thead>
<tbody>
{% for row in activity_rows %}
<tr>
<td>{{ row.time }}</td>
<td>{{ row.type }}</td>
<td>{{ row.box_type }}</td>
<td>{{ row.part_no }}</td>
<td>{% if row.delta > 0 %}+{% endif %}{{ row.delta }}</td>
</tr>
{% else %}
<tr>
<td colspan="5">暂无操作日志,先进行一次入库或编辑。</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</main>
<script>
(function () {
document.querySelectorAll('.bar-fill[data-width]').forEach(function (el) {
var value = Number(el.dataset.width || 0);
if (Number.isNaN(value)) {
value = 0;
}
value = Math.max(0, Math.min(100, value));
el.style.width = value + '%';
});
})();
</script>
</body>
</html>

34
templates/types.html Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分类总览</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<header class="hero">
<div>
<h1>分类总览</h1>
<p>将容器拆分为独立界面,避免长页面翻找</p>
</div>
<div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
<a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a>
</div>
</header>
<main class="container">
<section class="metrics-grid">
{% for item in type_cards %}
<article class="metric-card type-card">
<p class="metric-title">{{ item.label }}</p>
<p class="metric-value">{{ item.count }}</p>
<p class="hint">{{ item.desc }}</p>
<a class="btn" href="{{ item.url }}">进入分类</a>
</article>
{% endfor %}
</section>
</main>
</body>
</html>