feat:增强框类型管理和搜索功能

- 引入基于 JSON 的框类型覆盖,允许动态更新标签、描述和前缀。
- 增加了一种可调节容量的定制盒型。
- 实现了应用和保存盒子类型覆盖的函数。
- 更新仪表盘,显示按箱型分组的库存低库存商品。
- 创建了一个新的搜索页面,方便快速访问具有增强搜索功能的组件。
- 用搜索页面取代扫描页面,将出站功能直接集成到搜索结果中。
- 改进的界面元素,提升导航和用户体验,包括新增按钮和样式。
- 移除过时的 scanner.js 文件并将其功能集成到搜索页面。
- 更新了各种模板,以反映新的搜索和框类型管理功能。
This commit is contained in:
2026-03-11 16:01:11 +08:00
parent 0a54bfd5aa
commit 6f4a8d82f3
12 changed files with 524 additions and 276 deletions

View File

@@ -31,13 +31,10 @@ inventory/
│ ├── index.html │ ├── index.html
│ ├── box.html │ ├── box.html
│ ├── edit.html │ ├── edit.html
│ ├── scan.html
│ └── stats.html │ └── stats.html
└── static/ └── static/
├── css/ ├── css/
│ └── style.css │ └── style.css
└── js/
└── scanner.js
``` ```
## 2. 本地运行 ## 2. 本地运行
@@ -68,9 +65,9 @@ python app.py
### 3.1 首页 `/` ### 3.1 首页 `/`
- 首页已改为入口跳转到 `分类总览` 页面。 - 首页已改为入口跳转到 `仓库概览` 页面。
### 3.1.1 分类总览 `/types` ### 3.1.1 仓库概览 `/types`
- 展示三类独立界面入口:`28格小盒大盒``14格中盒大盒``袋装清单` - 展示三类独立界面入口:`28格小盒大盒``14格中盒大盒``袋装清单`
- 每类入口显示当前容器数量,点击进入单独分类页面。 - 每类入口显示当前容器数量,点击进入单独分类页面。
@@ -87,24 +84,19 @@ python app.py
- `袋装清单` 仅使用编号前缀(如 `BAG`),不设置编号范围。 - `袋装清单` 仅使用编号前缀(如 `BAG`),不设置编号范围。
- `28格/14格` 支持快速入库:多行粘贴后自动分配空位。 - `28格/14格` 支持快速入库:多行粘贴后自动分配空位。
- 支持按当前盒子导出打标 CSV仅导出启用记录可用于热敏打标机导入。 - 支持按当前盒子导出打标 CSV仅导出启用记录可用于热敏打标机导入。
- 打标 CSV 列名为中英双语格式(如 `料号(part_no)``打标文本(label_text)`),便于直接识别。 - 打标 CSV 列名为中英双语格式(如 `料号(part_no)``备注(note)`),便于直接识别。
### 3.3 编辑页 `/edit/<box_id>/<slot>` ### 3.3 编辑页 `/edit/<box_id>/<slot>`
- 编辑料号、名称、规格、数量、位置备注、备注。 - 编辑料号、名称、规格、数量、备注。
- 支持勾选启用,或通过按钮启用/停用。 - 通过按钮启用/停用。
- 可删除当前格子记录。 - 可删除当前格子记录。
### 3.4 扫码/搜索 `/scan` ### 3.4 统计页 `/stats`
- 可按料号或名称搜索。
- 支持扫码枪输入后回车触发搜索。
### 3.5 统计页 `/stats`
- 独立统计页,仅展示核心指标:`库存总量 / 分类占比 / 变动趋势` - 独立统计页,仅展示核心指标:`库存总量 / 分类占比 / 变动趋势`
- 支持 `7天``30天` 视图切换:`/stats?days=7``/stats?days=30` - 支持 `7天``30天` 视图切换:`/stats?days=7``/stats?days=30`
- 支持分类筛选:`/stats?days=30&box_type=small_28`(可选值:`small_28``medium_14``bag``all`)。 - 支持分类筛选:`/stats?days=30&box_type=small_28`(可选值:`small_28``medium_14``custom``bag``all`)。
- 趋势图基于库存变动日志实时计算,来源包括:新增、快速入库、启用/停用、删除。 - 趋势图基于库存变动日志实时计算,来源包括:新增、快速入库、启用/停用、删除。
- 说明:升级前的历史操作不会自动回溯写入日志,趋势从启用该版本后开始逐步真实化。 - 说明:升级前的历史操作不会自动回溯写入日志,趋势从启用该版本后开始逐步真实化。
- 新增最近操作时间线(最新 20 条),便于追踪库存变化来源。 - 新增最近操作时间线(最新 20 条),便于追踪库存变化来源。
@@ -112,12 +104,18 @@ python app.py
- 支持趋势数据导出 CSV`/stats/export?days=7&box_type=all`(包含 `daily_delta` 日增减列)。 - 支持趋势数据导出 CSV`/stats/export?days=7&box_type=all`(包含 `daily_delta` 日增减列)。
- 支持清除统计日志(当前筛选或全部),仅影响统计与趋势,不影响库存数据本体。 - 支持清除统计日志(当前筛选或全部),仅影响统计与趋势,不影响库存数据本体。
### 3.5 快速搜索与出库 `/search`
- 支持按 `料号``名称` 搜索已启用元件。
- 搜索结果可一键跳转到对应盒位编辑页。
- 支持快速出库:只填写数量即可扣减库存,并写入统计日志。
## 4. 袋装批量新增格式 ## 4. 袋装批量新增格式
在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔: 在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔:
```text ```text
料号, 名称, 数量, 规格, 位置备注, 备注 料号, 名称, 数量, 规格, 备注
``` ```
示例: 示例:

280
app.py
View File

@@ -1,6 +1,8 @@
import os import os
import re import re
import csv import csv
import json
from copy import deepcopy
from io import StringIO from io import StringIO
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -19,9 +21,10 @@ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db = SQLAlchemy(app) db = SQLAlchemy(app)
LOW_STOCK_THRESHOLD = 5 LOW_STOCK_THRESHOLD = 5
BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json")
BOX_TYPES = { DEFAULT_BOX_TYPES = {
"small_28": { "small_28": {
"label": "28格小盒大盒", "label": "28格小盒大盒",
"default_capacity": 28, "default_capacity": 28,
@@ -34,6 +37,12 @@ BOX_TYPES = {
"default_desc": "14格中盒内部摆放方向与28格不同", "default_desc": "14格中盒内部摆放方向与28格不同",
"default_prefix": "B", "default_prefix": "B",
}, },
"custom": {
"label": "自定义容器",
"default_capacity": 20,
"default_desc": "可按实际盒型设置格数与编号前缀",
"default_prefix": "C",
},
"bag": { "bag": {
"label": "袋装清单", "label": "袋装清单",
"default_capacity": 1, "default_capacity": 1,
@@ -42,6 +51,52 @@ BOX_TYPES = {
}, },
} }
BOX_TYPES = deepcopy(DEFAULT_BOX_TYPES)
def _apply_box_type_overrides() -> None:
if not os.path.exists(BOX_TYPES_OVERRIDE_PATH):
return
try:
with open(BOX_TYPES_OVERRIDE_PATH, "r", encoding="utf-8") as f:
overrides = json.load(f)
except (OSError, json.JSONDecodeError):
return
if not isinstance(overrides, dict):
return
for key, value in overrides.items():
if key not in BOX_TYPES or not isinstance(value, dict):
continue
for field in ("label", "default_desc", "default_prefix"):
if field not in value:
continue
BOX_TYPES[key][field] = value[field]
# Keep bag list capacity fixed by domain rule.
BOX_TYPES["bag"]["default_capacity"] = 1
def _save_box_type_overrides() -> None:
payload = {}
for key, defaults in DEFAULT_BOX_TYPES.items():
current = BOX_TYPES.get(key, defaults)
changed = {}
for field in ("label", "default_desc", "default_prefix"):
if current.get(field) != defaults.get(field):
changed[field] = current.get(field)
if changed:
payload[key] = changed
with open(BOX_TYPES_OVERRIDE_PATH, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
_apply_box_type_overrides()
class Box(db.Model): class Box(db.Model):
__tablename__ = "boxes" __tablename__ = "boxes"
@@ -512,6 +567,7 @@ def event_type_label(event_type: str) -> str:
labels = { labels = {
"quick_inbound_add": "快速入库新增", "quick_inbound_add": "快速入库新增",
"quick_inbound_merge": "快速入库合并", "quick_inbound_merge": "快速入库合并",
"component_outbound": "快速出库",
"component_save": "编辑保存", "component_save": "编辑保存",
"component_enable": "启用元件", "component_enable": "启用元件",
"component_disable": "停用元件", "component_disable": "停用元件",
@@ -581,11 +637,14 @@ def build_dashboard_context():
low_stock_items = [] low_stock_items = []
for c in sorted(low_stock_components, key=lambda item: (item.quantity, item.name or ""))[:12]: for c in sorted(low_stock_components, key=lambda item: (item.quantity, item.name or ""))[:12]:
box = box_by_id.get(c.box_id) box = box_by_id.get(c.box_id)
box_type_key = box.box_type if box and box.box_type in BOX_TYPES else "small_28"
low_stock_items.append( low_stock_items.append(
{ {
"name": c.name, "name": c.name,
"part_no": c.part_no, "part_no": c.part_no,
"quantity": c.quantity, "quantity": c.quantity,
"box_type": box_type_key,
"box_type_label": BOX_TYPES[box_type_key]["label"],
"box_name": box.name if box else f"{c.box_id}", "box_name": box.name if box else f"{c.box_id}",
"slot_code": slot_code_for_box(box, c.slot_index) if box else str(c.slot_index), "slot_code": slot_code_for_box(box, c.slot_index) if box else str(c.slot_index),
"edit_url": url_for("edit_component", box_id=c.box_id, slot=c.slot_index), "edit_url": url_for("edit_component", box_id=c.box_id, slot=c.slot_index),
@@ -636,19 +695,82 @@ def index():
@app.route("/types") @app.route("/types")
def types_page(): def types_page():
dashboard = build_dashboard_context() dashboard = build_dashboard_context()
notice = request.args.get("notice", "").strip()
error = request.args.get("error", "").strip()
category_stats_map = {item["key"]: item for item in dashboard["category_stats"]}
low_stock_groups = []
for key, meta in BOX_TYPES.items():
grouped_items = [
item
for item in dashboard["low_stock_items"]
if item.get("box_type") == key
]
low_stock_groups.append(
{
"key": key,
"label": meta["label"],
"items": grouped_items,
}
)
type_cards = [] type_cards = []
for key, meta in BOX_TYPES.items(): for key, meta in BOX_TYPES.items():
category_item = category_stats_map.get(key, {})
type_cards.append( type_cards.append(
{ {
"key": key, "key": key,
"label": meta["label"], "label": meta["label"],
"desc": meta["default_desc"], "desc": meta["default_desc"],
"count": len(dashboard["groups"].get(key, [])), "count": len(dashboard["groups"].get(key, [])),
"item_count": int(category_item.get("item_count", 0)),
"quantity": int(category_item.get("quantity", 0)),
"url": url_for("type_page", box_type=key), "url": url_for("type_page", box_type=key),
} }
) )
return render_template("types.html", type_cards=type_cards) return render_template(
"types.html",
type_cards=type_cards,
stats=dashboard["stats"],
low_stock_groups=low_stock_groups,
notice=notice,
error=error,
)
@app.route("/container-type/<box_type>/edit", methods=["GET", "POST"])
def edit_container_type(box_type: str):
if box_type not in BOX_TYPES:
return bad_request("无效盒子类型", "")
meta = BOX_TYPES[box_type]
error = ""
if request.method == "POST":
label = request.form.get("label", "").strip()
default_desc = request.form.get("default_desc", "").strip()
default_prefix = request.form.get("default_prefix", "").strip().upper()
if not label:
error = "容器名称不能为空"
elif not default_prefix:
error = "默认前缀不能为空"
if not error:
meta["label"] = label
meta["default_desc"] = default_desc or DEFAULT_BOX_TYPES[box_type]["default_desc"]
meta["default_prefix"] = default_prefix
_save_box_type_overrides()
return redirect(url_for("types_page", notice="容器属性已更新"))
return render_template(
"type_edit.html",
box_type=box_type,
meta=meta,
error=error,
)
@app.route("/type/<box_type>") @app.route("/type/<box_type>")
@@ -691,12 +813,24 @@ def create_box():
return bad_request("起始序号必须是大于等于 0 的整数", box_type) return bad_request("起始序号必须是大于等于 0 的整数", box_type)
meta = BOX_TYPES[box_type] meta = BOX_TYPES[box_type]
slot_capacity = meta["default_capacity"]
if box_type == "custom":
try:
slot_capacity = _parse_non_negative_int(
request.form.get("slot_capacity", str(meta["default_capacity"])),
meta["default_capacity"],
)
except ValueError:
return bad_request("格数必须是大于等于 1 的整数", box_type)
if slot_capacity < 1:
return bad_request("格数必须是大于等于 1 的整数", box_type)
effective_prefix = slot_prefix or meta["default_prefix"] effective_prefix = slot_prefix or meta["default_prefix"]
conflict, other_box = has_range_conflict( conflict, other_box = has_range_conflict(
box_type=box_type, box_type=box_type,
prefix=effective_prefix, prefix=effective_prefix,
start_number=start_number, start_number=start_number,
slot_capacity=meta["default_capacity"], slot_capacity=slot_capacity,
) )
if conflict: if conflict:
return bad_request( return bad_request(
@@ -710,7 +844,7 @@ def create_box():
base_name=base_name, base_name=base_name,
prefix=effective_prefix, prefix=effective_prefix,
start_number=start_number, start_number=start_number,
slot_capacity=meta["default_capacity"], slot_capacity=slot_capacity,
) )
final_name = make_unique_box_name(generated_name) final_name = make_unique_box_name(generated_name)
@@ -718,7 +852,7 @@ def create_box():
name=final_name, name=final_name,
description=description or meta["default_desc"], description=description or meta["default_desc"],
box_type=box_type, box_type=box_type,
slot_capacity=meta["default_capacity"], slot_capacity=slot_capacity,
slot_prefix=effective_prefix, slot_prefix=effective_prefix,
start_number=start_number, start_number=start_number,
) )
@@ -745,12 +879,36 @@ def update_box(box_id: int):
except ValueError: except ValueError:
return bad_request("起始序号必须是大于等于 0 的整数", box.box_type) return bad_request("起始序号必须是大于等于 0 的整数", box.box_type)
slot_capacity = box.slot_capacity
if box.box_type == "custom":
try:
slot_capacity = _parse_non_negative_int(
request.form.get("slot_capacity", str(box.slot_capacity)),
box.slot_capacity,
)
except ValueError:
return bad_request("格数必须是大于等于 1 的整数", box.box_type)
if slot_capacity < 1:
return bad_request("格数必须是大于等于 1 的整数", box.box_type)
max_used_slot = (
db.session.query(db.func.max(Component.slot_index))
.filter_by(box_id=box.id)
.scalar()
or 0
)
if max_used_slot > slot_capacity:
return bad_request(
f"格数不能小于已使用位置 {max_used_slot}",
box.box_type,
)
effective_prefix = slot_prefix or BOX_TYPES[box.box_type]["default_prefix"] effective_prefix = slot_prefix or BOX_TYPES[box.box_type]["default_prefix"]
conflict, other_box = has_range_conflict( conflict, other_box = has_range_conflict(
box_type=box.box_type, box_type=box.box_type,
prefix=effective_prefix, prefix=effective_prefix,
start_number=start_number, start_number=start_number,
slot_capacity=box.slot_capacity, slot_capacity=slot_capacity,
exclude_box_id=box.id, exclude_box_id=box.id,
) )
if conflict: if conflict:
@@ -765,13 +923,14 @@ def update_box(box_id: int):
base_name=base_name, base_name=base_name,
prefix=effective_prefix, prefix=effective_prefix,
start_number=start_number, start_number=start_number,
slot_capacity=box.slot_capacity, slot_capacity=slot_capacity,
) )
box.name = make_unique_box_name(generated_name, exclude_box_id=box.id) box.name = make_unique_box_name(generated_name, exclude_box_id=box.id)
box.description = description or BOX_TYPES[box.box_type]["default_desc"] box.description = description or BOX_TYPES[box.box_type]["default_desc"]
box.slot_prefix = effective_prefix box.slot_prefix = effective_prefix
box.start_number = start_number box.start_number = start_number
box.slot_capacity = slot_capacity
db.session.commit() db.session.commit()
return_to_type = parse_box_type_filter(request.form.get("return_to_type", "")) return_to_type = parse_box_type_filter(request.form.get("return_to_type", ""))
target_type = return_to_type if return_to_type != "all" else box.box_type target_type = return_to_type if return_to_type != "all" else box.box_type
@@ -813,6 +972,17 @@ def suggest_start():
exclude_box_id = None exclude_box_id = None
slot_capacity = BOX_TYPES[box_type]["default_capacity"] slot_capacity = BOX_TYPES[box_type]["default_capacity"]
if box_type == "custom" and not box_id_raw:
try:
slot_capacity = _parse_non_negative_int(
request.args.get("slot_capacity", str(slot_capacity)),
slot_capacity,
)
except ValueError:
return {"ok": False, "message": "格数必须是大于等于 1 的整数"}, 400
if slot_capacity < 1:
return {"ok": False, "message": "格数必须是大于等于 1 的整数"}, 400
if box_id_raw: if box_id_raw:
try: try:
box_id = int(box_id_raw) box_id = int(box_id_raw)
@@ -1195,10 +1365,13 @@ def edit_component(box_id: int, slot: int):
if slot < 1 or slot > box.slot_capacity: if slot < 1 or slot > box.slot_capacity:
return "无效的格子编号", 400 return "无效的格子编号", 400
search_query = request.args.get("q", "").strip()
component = Component.query.filter_by(box_id=box.id, slot_index=slot).first() component = Component.query.filter_by(box_id=box.id, slot_index=slot).first()
if request.method == "POST": if request.method == "POST":
action = request.form.get("action", "save") action = request.form.get("action", "save")
search_query_post = request.form.get("q", "").strip()
search_query_effective = search_query_post or search_query
if action == "delete": if action == "delete":
if component: if component:
@@ -1211,6 +1384,8 @@ def edit_component(box_id: int, slot: int):
) )
db.session.delete(component) db.session.delete(component)
db.session.commit() db.session.commit()
if search_query_effective:
return redirect(url_for("search_page", q=search_query_effective))
return redirect(url_for("view_box", box_id=box.id)) return redirect(url_for("view_box", box_id=box.id))
if action == "toggle_enable": if action == "toggle_enable":
@@ -1225,7 +1400,7 @@ def edit_component(box_id: int, slot: int):
component=component, component=component,
) )
db.session.commit() db.session.commit()
return redirect(url_for("edit_component", box_id=box.id, slot=slot)) return redirect(url_for("edit_component", box_id=box.id, slot=slot, q=search_query_post))
if action == "toggle_disable": if action == "toggle_disable":
if component: if component:
@@ -1239,7 +1414,7 @@ def edit_component(box_id: int, slot: int):
component=component, component=component,
) )
db.session.commit() db.session.commit()
return redirect(url_for("edit_component", box_id=box.id, slot=slot)) return redirect(url_for("edit_component", box_id=box.id, slot=slot, q=search_query_post))
part_no = request.form.get("part_no", "").strip() part_no = request.form.get("part_no", "").strip()
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
@@ -1256,6 +1431,7 @@ def edit_component(box_id: int, slot: int):
slot_code=slot_code_for_box(box, slot), slot_code=slot_code_for_box(box, slot),
component=component, component=component,
error=error, error=error,
search_query=search_query_post,
) )
try: try:
@@ -1269,6 +1445,7 @@ def edit_component(box_id: int, slot: int):
slot_code=slot_code_for_box(box, slot), slot_code=slot_code_for_box(box, slot),
component=component, component=component,
error=error, error=error,
search_query=search_query_post,
) )
old_enabled_qty = 0 old_enabled_qty = 0
@@ -1299,6 +1476,8 @@ def edit_component(box_id: int, slot: int):
) )
db.session.commit() db.session.commit()
if search_query_effective:
return redirect(url_for("search_page", q=search_query_effective))
return redirect(url_for("view_box", box_id=box.id)) return redirect(url_for("view_box", box_id=box.id))
return render_template( return render_template(
@@ -1307,24 +1486,28 @@ def edit_component(box_id: int, slot: int):
slot=slot, slot=slot,
slot_code=slot_code_for_box(box, slot), slot_code=slot_code_for_box(box, slot),
component=component, component=component,
search_query=search_query,
) )
@app.route("/scan") @app.route("/search")
def scan_page(): def search_page():
keyword = request.args.get("q", "").strip() keyword = request.args.get("q", "").strip()
notice = request.args.get("notice", "").strip()
error = request.args.get("error", "").strip()
results = [] results = []
if keyword: if keyword:
raw_results = ( raw_results = (
Component.query.join(Box, Box.id == Component.box_id) Component.query.join(Box, Box.id == Component.box_id)
.filter( .filter(
Component.is_enabled.is_(True),
db.or_( db.or_(
Component.part_no.ilike(f"%{keyword}%"), Component.part_no.ilike(f"%{keyword}%"),
Component.name.ilike(f"%{keyword}%"), Component.name.ilike(f"%{keyword}%"),
) ),
) )
.order_by(Component.part_no.asc()) .order_by(Component.part_no.asc(), Component.name.asc())
.all() .all()
) )
@@ -1335,10 +1518,52 @@ def scan_page():
"component": c, "component": c,
"box_name": box.name if box else f"{c.box_id}", "box_name": box.name if box else f"{c.box_id}",
"slot_code": slot_code_for_box(box, c.slot_index) if box else str(c.slot_index), "slot_code": slot_code_for_box(box, c.slot_index) if box else str(c.slot_index),
"edit_url": url_for("edit_component", box_id=c.box_id, slot=c.slot_index, q=keyword),
} }
) )
return render_template("scan.html", keyword=keyword, results=results) return render_template(
"search.html",
keyword=keyword,
results=results,
notice=notice,
error=error,
)
@app.route("/component/<int:component_id>/outbound", methods=["POST"])
def quick_outbound(component_id: int):
component = Component.query.get_or_404(component_id)
keyword = request.form.get("q", "").strip()
try:
amount = _parse_non_negative_int(request.form.get("amount", "0"), 0)
except ValueError:
return redirect(url_for("search_page", q=keyword, error="出库数量必须是大于等于 0 的整数"))
if amount <= 0:
return redirect(url_for("search_page", q=keyword, error="出库数量必须大于 0"))
if not component.is_enabled:
return redirect(url_for("search_page", q=keyword, error="该元件已停用,不能出库"))
if amount > int(component.quantity or 0):
return redirect(url_for("search_page", q=keyword, error="出库数量超过当前库存"))
component.quantity = int(component.quantity or 0) - amount
box = Box.query.get(component.box_id)
log_inventory_event(
event_type="component_outbound",
delta=-amount,
box=box,
component=component,
part_no=component.part_no,
)
db.session.commit()
slot_code = slot_code_for_box(box, component.slot_index) if box else str(component.slot_index)
notice = f"出库成功: {component.part_no} -{amount}{slot_code}"
return redirect(url_for("search_page", q=keyword, notice=notice))
@app.route("/stats") @app.route("/stats")
@@ -1580,33 +1805,6 @@ def clear_stats_logs():
return redirect(url_for("stats_page", days=days, box_type=box_type_filter, notice=notice)) return redirect(url_for("stats_page", days=days, box_type=box_type_filter, notice=notice))
@app.route("/api/scan")
def scan_api():
code = request.args.get("code", "").strip()
if not code:
return {"ok": False, "message": "code 不能为空"}, 400
item = Component.query.filter_by(part_no=code).first()
if not item:
return {"ok": False, "message": "未找到元件"}, 404
box = Box.query.get(item.box_id)
return {
"ok": True,
"data": {
"id": item.id,
"part_no": item.part_no,
"name": item.name,
"quantity": item.quantity,
"box_id": item.box_id,
"box_name": box.name if box else None,
"slot_index": item.slot_index,
"slot_code": slot_code_for_box(box, item.slot_index) if box else str(item.slot_index),
"is_enabled": bool(item.is_enabled),
},
}
def bootstrap() -> None: def bootstrap() -> None:
with app.app_context(): with app.app_context():
db.create_all() db.create_all()

View File

@@ -421,6 +421,34 @@ body {
gap: var(--space-1); gap: var(--space-1);
} }
.type-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-1);
}
.type-card-more {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--card-alt);
color: var(--muted);
text-decoration: none;
font-weight: 700;
line-height: 1;
}
.type-card-more:hover {
color: var(--accent-press);
border-color: color-mix(in srgb, var(--accent) 60%, var(--line));
background: color-mix(in srgb, var(--card-alt) 76%, var(--accent) 24%);
}
.catalog-content { .catalog-content {
min-width: 0; min-width: 0;
} }
@@ -772,6 +800,16 @@ input[type="checkbox"] {
flex: 1; flex: 1;
} }
.search-outbound-form {
display: flex;
gap: 8px;
align-items: center;
}
.search-outbound-form input[type="number"] {
width: 88px;
}
.table-wrap { .table-wrap {
overflow-x: auto; overflow-x: auto;
} }

View File

@@ -1,45 +0,0 @@
(function () {
const input = document.getElementById("scan-input");
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) {
return;
}
// Keep focus for barcode scanners that type and send Enter immediately.
window.addEventListener("load", function () {
input.focus();
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
input.value = "";
input.focus();
showToast("已清空搜索词");
}
});
form.addEventListener("submit", function () {
if (input.value.trim()) {
showToast("正在搜索...");
}
});
})();

View File

@@ -14,9 +14,10 @@
</div> </div>
<nav class="hero-actions"> <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('type_page', box_type=box.box_type) }}">返回上一级容器</a>
<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('stats_page') }}">统计页</a>
<a class="btn btn-light" href="{{ url_for('export_box_labels_csv', box_id=box.id) }}">导出打标CSV</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>
</nav> </nav>
</header> </header>

View File

@@ -13,6 +13,7 @@
<p>步骤: 填写核心字段 -> 检查数量 -> 保存</p> <p>步骤: 填写核心字段 -> 检查数量 -> 保存</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('search_page', q=search_query) if search_query else url_for('search_page') }}">返回快速搜索</a>
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a> <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> <a class="btn btn-light" href="{{ url_for('view_box', box_id=box.id) }}">返回宫格</a>
</div> </div>
@@ -26,6 +27,7 @@
<div class="entry-shell"> <div class="entry-shell">
<section class="entry-main"> <section class="entry-main">
<form class="panel form-grid" method="post"> <form class="panel form-grid" method="post">
<input type="hidden" name="q" value="{{ search_query or '' }}">
<label> <label>
料号 * 料号 *
<input type="text" name="part_no" required value="{{ component.part_no if component else '' }}" aria-label="料号" placeholder="如 STM32F103C8T6"> <input type="text" name="part_no" required value="{{ component.part_no if component else '' }}" aria-label="料号" placeholder="如 STM32F103C8T6">

View File

@@ -13,17 +13,16 @@
<p>{% if separate_mode %}当前为独立分类界面,减少长列表翻找成本{% else %}极简中性灰布局,聚焦数量/分类/变动核心信息{% endif %}</p> <p>{% if separate_mode %}当前为独立分类界面,减少长列表翻找成本{% else %}极简中性灰布局,聚焦数量/分类/变动核心信息{% endif %}</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('types_page') }}">分类总</a> <a class="btn btn-light" href="{{ url_for('types_page') }}">仓库概</a>
<a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
<a class="btn btn-light" href="#quick-add">新增库存</a> <a class="btn btn-light" href="#quick-add">新增库存</a>
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a> <a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
<a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a>
</div> </div>
</header> </header>
<main class="container"> <main class="container">
<div class="layout-shell"> <div class="layout-shell">
<aside class="catalog-sidebar"> <aside class="catalog-sidebar">
<button class="sidebar-toggle btn btn-light" type="button" aria-expanded="false" aria-controls="side-low-stock">低库存面板</button>
<section class="panel" id="sidebar-nav-panel"> <section class="panel" id="sidebar-nav-panel">
<h2>容器导航</h2> <h2>容器导航</h2>
<div class="card-actions icon-links" aria-label="快捷功能"> <div class="card-actions icon-links" aria-label="快捷功能">
@@ -36,9 +35,6 @@
<a class="icon-link" href="#quick-add" title="跳转新增库存" aria-label="跳转新增库存"> <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> <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>
<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> </div>
<nav class="catalog-nav" id="catalog-nav-links"> <nav class="catalog-nav" id="catalog-nav-links">
{% for key, meta in box_types.items() %} {% for key, meta in box_types.items() %}
@@ -46,45 +42,6 @@
{% endfor %} {% endfor %}
</nav> </nav>
</section> </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">
@@ -109,6 +66,9 @@
{% if separate_mode %}<input type="hidden" name="return_to_type" value="{{ current_box_type }}">{% endif %} {% 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="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)">
{% if key == 'custom' %}
<input type="number" name="slot_capacity" min="1" value="{{ meta.default_capacity }}" placeholder="格数" required>
{% endif %}
<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="备注(可选)">
<button class="btn btn-light suggest-start-btn" type="button" data-box-type="{{ key }}">建议起始号</button> <button class="btn btn-light suggest-start-btn" type="button" data-box-type="{{ key }}">建议起始号</button>
@@ -127,6 +87,9 @@
{% if item.box.box_type == 'bag' %} {% if item.box.box_type == 'bag' %}
<p>编号前缀: {{ item.box.slot_prefix }} | 袋装清单不使用范围</p> <p>编号前缀: {{ item.box.slot_prefix }} | 袋装清单不使用范围</p>
<p>已记录: {{ item.used_count }} 项</p> <p>已记录: {{ item.used_count }} 项</p>
{% elif item.box.box_type == 'custom' %}
<p>格数: {{ item.box.slot_capacity }} | 编号前缀: {{ item.box.slot_prefix }} | 范围: {{ item.slot_range }}</p>
<p>已启用: {{ item.used_count }}/{{ item.box.slot_capacity }}</p>
{% else %} {% 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>
@@ -162,6 +125,9 @@
{% if separate_mode %}<input type="hidden" name="return_to_type" value="{{ current_box_type }}">{% endif %} {% 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>
{% if item.box.box_type == 'custom' %}
<input type="number" name="slot_capacity" min="1" value="{{ item.box.slot_capacity }}" required>
{% endif %}
<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>
<input type="text" name="description" value="{{ item.box.description or '' }}"> <input type="text" name="description" value="{{ item.box.description or '' }}">
<button class="btn btn-light suggest-start-btn" type="button" data-box-id="{{ item.box.id }}" data-box-type="{{ item.box.box_type }}">建议起始号</button> <button class="btn btn-light suggest-start-btn" type="button" data-box-id="{{ item.box.id }}" data-box-type="{{ item.box.box_type }}">建议起始号</button>
@@ -182,53 +148,6 @@
<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) { function showToast(message) {
var stack = document.querySelector('.toast-stack'); var stack = document.querySelector('.toast-stack');
if (!stack) { if (!stack) {
@@ -273,6 +192,8 @@
var boxType = btn.dataset.boxType || 'small_28'; var boxType = btn.dataset.boxType || 'small_28';
var boxId = btn.dataset.boxId || ''; var boxId = btn.dataset.boxId || '';
var prefix = prefixInput ? prefixInput.value.trim() : ''; var prefix = prefixInput ? prefixInput.value.trim() : '';
var slotCapacityInput = form.querySelector('input[name="slot_capacity"]');
var slotCapacity = slotCapacityInput ? slotCapacityInput.value.trim() : '';
var params = new URLSearchParams(); var params = new URLSearchParams();
params.set('box_type', boxType); params.set('box_type', boxType);
@@ -282,6 +203,9 @@
if (boxId) { if (boxId) {
params.set('box_id', boxId); params.set('box_id', boxId);
} }
if (slotCapacity) {
params.set('slot_capacity', slotCapacity);
}
fetch('{{ url_for('suggest_start') }}?' + params.toString()) fetch('{{ url_for('suggest_start') }}?' + params.toString())
.then(function (resp) { return resp.json(); }) .then(function (resp) { return resp.json(); })
@@ -297,8 +221,6 @@
}); });
}); });
}); });
bindSidebarCompactMode();
})(); })();
</script> </script>
</body> </body>

View File

@@ -1,73 +0,0 @@
<!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>
<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>
<main class="container">
<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">
<input id="scan-input" type="search" name="q" placeholder="输入或扫码料号/名称" value="{{ keyword }}" aria-label="搜索关键字">
<button class="btn" type="submit">搜索</button>
</form>
<p class="hint">扫码枪通常会自动输入后回车,可直接触发搜索。</p>
</section>
<section class="panel">
<h2>搜索结果</h2>
{% if keyword and results %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>料号</th>
<th>名称</th>
<th>库存</th>
<th>位置</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for row in results %}
{% set c = row.component %}
<tr>
<td>{{ c.part_no }}</td>
<td>{{ c.name }}</td>
<td>{{ c.quantity }}</td>
<td>{{ row.box_name }} / {{ row.slot_code }}</td>
<td>{% if c.is_enabled %}启用{% else %}停用{% endif %}</td>
<td><a class="btn btn-light" href="{{ url_for('edit_component', box_id=c.box_id, slot=c.slot_index) }}">编辑</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif keyword %}
<p>未找到关键词 "{{ keyword }}" 的元件。</p>
{% else %}
<p>请输入关键词开始搜索。</p>
{% endif %}
</section>
</main>
<script src="{{ url_for('static', filename='js/scanner.js') }}"></script>
</body>
</html>

109
templates/search.html Normal file
View File

@@ -0,0 +1,109 @@
<!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 btn-light" href="{{ url_for('stats_page') }}">统计页</a>
</nav>
</header>
<main class="container">
{% if error %}
<p class="alert">{{ error }}</p>
{% endif %}
{% if notice %}
<p class="notice">{{ notice }}</p>
{% endif %}
<section class="panel">
<form id="search-form" method="get" action="{{ url_for('search_page') }}" class="search-row">
<input id="search-input" type="search" name="q" placeholder="输入料号或名称" value="{{ keyword }}" aria-label="搜索关键字">
<button class="btn" type="submit">搜索</button>
</form>
<p class="hint">出库只需要输入数量,系统会自动扣减库存并记录统计。</p>
</section>
<section class="panel">
<h2>搜索结果</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>料号</th>
<th>名称</th>
<th>规格</th>
<th>库存</th>
<th>位置</th>
<th>跳转</th>
<th>出库</th>
</tr>
</thead>
<tbody>
{% for row in results %}
{% set c = row.component %}
<tr>
<td>{{ c.part_no }}</td>
<td>{{ c.name }}</td>
<td>{{ c.specification or '-' }}</td>
<td>{{ c.quantity }}</td>
<td>{{ row.box_name }} / {{ row.slot_code }}</td>
<td><a class="btn btn-light" href="{{ row.edit_url }}">进入位置</a></td>
<td>
<form method="post" action="{{ url_for('quick_outbound', component_id=c.id) }}" class="search-outbound-form">
<input type="hidden" name="q" value="{{ keyword }}">
<input type="number" name="amount" min="1" step="1" placeholder="数量" required class="outbound-amount">
<button class="btn" type="submit">出库</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="7">{% if keyword %}未找到匹配元件{% else %}先输入关键字进行搜索{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</main>
<script>
(function () {
var searchInput = document.getElementById('search-input');
var searchForm = document.getElementById('search-form');
if (searchInput && searchForm) {
searchInput.focus();
searchInput.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
searchForm.requestSubmit();
}
});
}
document.querySelectorAll('.outbound-amount').forEach(function (input) {
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
var form = input.closest('form');
if (form) {
form.requestSubmit();
}
}
});
});
})();
</script>
</body>
</html>

View File

@@ -14,7 +14,7 @@
</div> </div>
<nav class="hero-actions"> <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" href="{{ url_for('scan_page') }}">扫码/搜索</a> <a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
</nav> </nav>
</header> </header>

47
templates/type_edit.html Normal file
View File

@@ -0,0 +1,47 @@
<!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>
<div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
</div>
</header>
<main class="container">
{% if error %}
<p class="alert">{{ error }}</p>
{% endif %}
<section class="panel">
<form class="form-grid" method="post">
<label>
容器名称 *
<input type="text" name="label" required value="{{ meta.label }}">
</label>
<label>
默认前缀 *
<input type="text" name="default_prefix" required value="{{ meta.default_prefix }}">
</label>
<label class="full">
默认描述
<input type="text" name="default_desc" value="{{ meta.default_desc }}">
</label>
<div class="actions full">
<button class="btn" type="submit">保存属性</button>
<a class="btn btn-light" href="{{ url_for('types_page') }}">取消</a>
</div>
</form>
</section>
</main>
</body>
</html>

View File

@@ -3,32 +3,83 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分类总</title> <title>仓库概</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head> </head>
<body> <body>
<header class="hero"> <header class="hero">
<div> <div>
<h1>分类总</h1> <h1>仓库概</h1>
<p>将容器拆分为独立界面,避免长页面翻找</p> <p>先看关键指标与待补货,再进入对应分类处理</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<a class="btn" href="{{ url_for('type_page', box_type='custom') }}#quick-add">添加容器</a>
<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('stats_page') }}">统计页</a>
<a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a>
</div> </div>
</header> </header>
<main class="container"> <main class="container">
{% if error %}
<p class="alert">{{ error }}</p>
{% endif %}
{% if notice %}
<p class="notice">{{ notice }}</p>
{% endif %}
<section class="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">待补货元件</p>
<p class="metric-value">{{ stats.low_stock_count }}</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>
</section>
<section class="metrics-grid"> <section class="metrics-grid">
{% for item in type_cards %} {% for item in type_cards %}
<article class="metric-card type-card"> <article class="metric-card type-card">
<p class="metric-title">{{ item.label }}</p> <div class="type-card-head">
<p class="metric-title">{{ item.label }}</p>
<a class="type-card-more" href="{{ url_for('edit_container_type', box_type=item.key) }}" aria-label="编辑容器属性" title="编辑容器属性">...</a>
</div>
<p class="metric-value">{{ item.count }}</p> <p class="metric-value">{{ item.count }}</p>
<p class="hint">{{ item.desc }}</p> <p class="hint">{{ item.desc }}</p>
<p class="hint">容器 {{ item.count }} 个 | 启用元件 {{ item.item_count }} 种 | 总库存 {{ item.quantity }}</p>
<a class="btn" href="{{ item.url }}">进入分类</a> <a class="btn" href="{{ item.url }}">进入分类</a>
</article> </article>
{% endfor %} {% endfor %}
</section> </section>
<section class="panel side-low-stock" id="overview-low-stock">
<h2>低库存元器件</h2>
{% for group in low_stock_groups %}
<h3>{{ group['label'] }}{{ group['items']|length }}</h3>
<ul class="side-low-stock-list">
{% for item in group['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>
{% endfor %}
</section>
</main> </main>
</body> </body>
</html> </html>