feat:增强框类型管理和搜索功能
- 引入基于 JSON 的框类型覆盖,允许动态更新标签、描述和前缀。 - 增加了一种可调节容量的定制盒型。 - 实现了应用和保存盒子类型覆盖的函数。 - 更新仪表盘,显示按箱型分组的库存低库存商品。 - 创建了一个新的搜索页面,方便快速访问具有增强搜索功能的组件。 - 用搜索页面取代扫描页面,将出站功能直接集成到搜索结果中。 - 改进的界面元素,提升导航和用户体验,包括新增按钮和样式。 - 移除过时的 scanner.js 文件并将其功能集成到搜索页面。 - 更新了各种模板,以反映新的搜索和框类型管理功能。
This commit is contained in:
30
README.md
30
README.md
@@ -31,13 +31,10 @@ inventory/
|
||||
│ ├── index.html
|
||||
│ ├── box.html
|
||||
│ ├── edit.html
|
||||
│ ├── scan.html
|
||||
│ └── stats.html
|
||||
└── static/
|
||||
├── css/
|
||||
│ └── style.css
|
||||
└── js/
|
||||
└── scanner.js
|
||||
```
|
||||
|
||||
## 2. 本地运行
|
||||
@@ -68,9 +65,9 @@ python app.py
|
||||
|
||||
### 3.1 首页 `/`
|
||||
|
||||
- 首页已改为入口跳转到 `分类总览` 页面。
|
||||
- 首页已改为入口跳转到 `仓库概览` 页面。
|
||||
|
||||
### 3.1.1 分类总览 `/types`
|
||||
### 3.1.1 仓库概览 `/types`
|
||||
|
||||
- 展示三类独立界面入口:`28格小盒大盒`、`14格中盒大盒`、`袋装清单`。
|
||||
- 每类入口显示当前容器数量,点击进入单独分类页面。
|
||||
@@ -87,24 +84,19 @@ python app.py
|
||||
- `袋装清单` 仅使用编号前缀(如 `BAG`),不设置编号范围。
|
||||
- `28格/14格` 支持快速入库:多行粘贴后自动分配空位。
|
||||
- 支持按当前盒子导出打标 CSV(仅导出启用记录),可用于热敏打标机导入。
|
||||
- 打标 CSV 列名为中英双语格式(如 `料号(part_no)`、`打标文本(label_text)`),便于直接识别。
|
||||
- 打标 CSV 列名为中英双语格式(如 `料号(part_no)`、`备注(note)`),便于直接识别。
|
||||
|
||||
### 3.3 编辑页 `/edit/<box_id>/<slot>`
|
||||
|
||||
- 编辑料号、名称、规格、数量、位置备注、备注。
|
||||
- 支持勾选启用,或通过按钮启用/停用。
|
||||
- 编辑料号、名称、规格、数量、备注。
|
||||
- 通过按钮启用/停用。
|
||||
- 可删除当前格子记录。
|
||||
|
||||
### 3.4 扫码/搜索 `/scan`
|
||||
|
||||
- 可按料号或名称搜索。
|
||||
- 支持扫码枪输入后回车触发搜索。
|
||||
|
||||
### 3.5 统计页 `/stats`
|
||||
### 3.4 统计页 `/stats`
|
||||
|
||||
- 独立统计页,仅展示核心指标:`库存总量 / 分类占比 / 变动趋势`。
|
||||
- 支持 `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 条),便于追踪库存变化来源。
|
||||
@@ -112,12 +104,18 @@ python app.py
|
||||
- 支持趋势数据导出 CSV:`/stats/export?days=7&box_type=all`(包含 `daily_delta` 日增减列)。
|
||||
- 支持清除统计日志(当前筛选或全部),仅影响统计与趋势,不影响库存数据本体。
|
||||
|
||||
### 3.5 快速搜索与出库 `/search`
|
||||
|
||||
- 支持按 `料号` 或 `名称` 搜索已启用元件。
|
||||
- 搜索结果可一键跳转到对应盒位编辑页。
|
||||
- 支持快速出库:只填写数量即可扣减库存,并写入统计日志。
|
||||
|
||||
## 4. 袋装批量新增格式
|
||||
|
||||
在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔:
|
||||
|
||||
```text
|
||||
料号, 名称, 数量, 规格, 位置备注, 备注
|
||||
料号, 名称, 数量, 规格, 备注
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
280
app.py
280
app.py
@@ -1,6 +1,8 @@
|
||||
import os
|
||||
import re
|
||||
import csv
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from io import StringIO
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@@ -19,9 +21,10 @@ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
LOW_STOCK_THRESHOLD = 5
|
||||
BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json")
|
||||
|
||||
|
||||
BOX_TYPES = {
|
||||
DEFAULT_BOX_TYPES = {
|
||||
"small_28": {
|
||||
"label": "28格小盒大盒",
|
||||
"default_capacity": 28,
|
||||
@@ -34,6 +37,12 @@ BOX_TYPES = {
|
||||
"default_desc": "14格中盒,内部摆放方向与28格不同",
|
||||
"default_prefix": "B",
|
||||
},
|
||||
"custom": {
|
||||
"label": "自定义容器",
|
||||
"default_capacity": 20,
|
||||
"default_desc": "可按实际盒型设置格数与编号前缀",
|
||||
"default_prefix": "C",
|
||||
},
|
||||
"bag": {
|
||||
"label": "袋装清单",
|
||||
"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):
|
||||
__tablename__ = "boxes"
|
||||
@@ -512,6 +567,7 @@ def event_type_label(event_type: str) -> str:
|
||||
labels = {
|
||||
"quick_inbound_add": "快速入库新增",
|
||||
"quick_inbound_merge": "快速入库合并",
|
||||
"component_outbound": "快速出库",
|
||||
"component_save": "编辑保存",
|
||||
"component_enable": "启用元件",
|
||||
"component_disable": "停用元件",
|
||||
@@ -581,11 +637,14 @@ def build_dashboard_context():
|
||||
low_stock_items = []
|
||||
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_type_key = box.box_type if box and box.box_type in BOX_TYPES else "small_28"
|
||||
low_stock_items.append(
|
||||
{
|
||||
"name": c.name,
|
||||
"part_no": c.part_no,
|
||||
"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}",
|
||||
"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),
|
||||
@@ -636,19 +695,82 @@ def index():
|
||||
@app.route("/types")
|
||||
def types_page():
|
||||
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 = []
|
||||
for key, meta in BOX_TYPES.items():
|
||||
category_item = category_stats_map.get(key, {})
|
||||
type_cards.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": meta["label"],
|
||||
"desc": meta["default_desc"],
|
||||
"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),
|
||||
}
|
||||
)
|
||||
|
||||
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>")
|
||||
@@ -691,12 +813,24 @@ def create_box():
|
||||
return bad_request("起始序号必须是大于等于 0 的整数", 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"]
|
||||
conflict, other_box = has_range_conflict(
|
||||
box_type=box_type,
|
||||
prefix=effective_prefix,
|
||||
start_number=start_number,
|
||||
slot_capacity=meta["default_capacity"],
|
||||
slot_capacity=slot_capacity,
|
||||
)
|
||||
if conflict:
|
||||
return bad_request(
|
||||
@@ -710,7 +844,7 @@ def create_box():
|
||||
base_name=base_name,
|
||||
prefix=effective_prefix,
|
||||
start_number=start_number,
|
||||
slot_capacity=meta["default_capacity"],
|
||||
slot_capacity=slot_capacity,
|
||||
)
|
||||
final_name = make_unique_box_name(generated_name)
|
||||
|
||||
@@ -718,7 +852,7 @@ def create_box():
|
||||
name=final_name,
|
||||
description=description or meta["default_desc"],
|
||||
box_type=box_type,
|
||||
slot_capacity=meta["default_capacity"],
|
||||
slot_capacity=slot_capacity,
|
||||
slot_prefix=effective_prefix,
|
||||
start_number=start_number,
|
||||
)
|
||||
@@ -745,12 +879,36 @@ def update_box(box_id: int):
|
||||
except ValueError:
|
||||
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"]
|
||||
conflict, other_box = has_range_conflict(
|
||||
box_type=box.box_type,
|
||||
prefix=effective_prefix,
|
||||
start_number=start_number,
|
||||
slot_capacity=box.slot_capacity,
|
||||
slot_capacity=slot_capacity,
|
||||
exclude_box_id=box.id,
|
||||
)
|
||||
if conflict:
|
||||
@@ -765,13 +923,14 @@ def update_box(box_id: int):
|
||||
base_name=base_name,
|
||||
prefix=effective_prefix,
|
||||
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.description = description or BOX_TYPES[box.box_type]["default_desc"]
|
||||
box.slot_prefix = effective_prefix
|
||||
box.start_number = start_number
|
||||
box.slot_capacity = slot_capacity
|
||||
db.session.commit()
|
||||
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
|
||||
@@ -813,6 +972,17 @@ def suggest_start():
|
||||
exclude_box_id = None
|
||||
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:
|
||||
try:
|
||||
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:
|
||||
return "无效的格子编号", 400
|
||||
|
||||
search_query = request.args.get("q", "").strip()
|
||||
component = Component.query.filter_by(box_id=box.id, slot_index=slot).first()
|
||||
|
||||
if request.method == "POST":
|
||||
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 component:
|
||||
@@ -1211,6 +1384,8 @@ def edit_component(box_id: int, slot: int):
|
||||
)
|
||||
db.session.delete(component)
|
||||
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))
|
||||
|
||||
if action == "toggle_enable":
|
||||
@@ -1225,7 +1400,7 @@ def edit_component(box_id: int, slot: int):
|
||||
component=component,
|
||||
)
|
||||
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 component:
|
||||
@@ -1239,7 +1414,7 @@ def edit_component(box_id: int, slot: int):
|
||||
component=component,
|
||||
)
|
||||
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()
|
||||
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),
|
||||
component=component,
|
||||
error=error,
|
||||
search_query=search_query_post,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1269,6 +1445,7 @@ def edit_component(box_id: int, slot: int):
|
||||
slot_code=slot_code_for_box(box, slot),
|
||||
component=component,
|
||||
error=error,
|
||||
search_query=search_query_post,
|
||||
)
|
||||
|
||||
old_enabled_qty = 0
|
||||
@@ -1299,6 +1476,8 @@ def edit_component(box_id: int, slot: int):
|
||||
)
|
||||
|
||||
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 render_template(
|
||||
@@ -1307,24 +1486,28 @@ def edit_component(box_id: int, slot: int):
|
||||
slot=slot,
|
||||
slot_code=slot_code_for_box(box, slot),
|
||||
component=component,
|
||||
search_query=search_query,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/scan")
|
||||
def scan_page():
|
||||
@app.route("/search")
|
||||
def search_page():
|
||||
keyword = request.args.get("q", "").strip()
|
||||
notice = request.args.get("notice", "").strip()
|
||||
error = request.args.get("error", "").strip()
|
||||
results = []
|
||||
|
||||
if keyword:
|
||||
raw_results = (
|
||||
Component.query.join(Box, Box.id == Component.box_id)
|
||||
.filter(
|
||||
Component.is_enabled.is_(True),
|
||||
db.or_(
|
||||
Component.part_no.ilike(f"%{keyword}%"),
|
||||
Component.name.ilike(f"%{keyword}%"),
|
||||
)
|
||||
),
|
||||
)
|
||||
.order_by(Component.part_no.asc())
|
||||
.order_by(Component.part_no.asc(), Component.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -1335,10 +1518,52 @@ def scan_page():
|
||||
"component": c,
|
||||
"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),
|
||||
"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")
|
||||
@@ -1580,33 +1805,6 @@ def clear_stats_logs():
|
||||
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:
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
@@ -421,6 +421,34 @@ body {
|
||||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -772,6 +800,16 @@ input[type="checkbox"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-outbound-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-outbound-form input[type="number"] {
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@@ -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("正在搜索...");
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -14,9 +14,10 @@
|
||||
</div>
|
||||
<nav class="hero-actions">
|
||||
<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('export_box_labels_csv', box_id=box.id) }}">导出打标CSV</a>
|
||||
<a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<p>步骤: 填写核心字段 -> 检查数量 -> 保存</p>
|
||||
</div>
|
||||
<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('view_box', box_id=box.id) }}">返回宫格</a>
|
||||
</div>
|
||||
@@ -26,6 +27,7 @@
|
||||
<div class="entry-shell">
|
||||
<section class="entry-main">
|
||||
<form class="panel form-grid" method="post">
|
||||
<input type="hidden" name="q" value="{{ search_query or '' }}">
|
||||
<label>
|
||||
料号 *
|
||||
<input type="text" name="part_no" required value="{{ component.part_no if component else '' }}" aria-label="料号" placeholder="如 STM32F103C8T6">
|
||||
|
||||
@@ -13,17 +13,16 @@
|
||||
<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="{{ 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="{{ url_for('stats_page') }}">统计页</a>
|
||||
<a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<div class="layout-shell">
|
||||
<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">
|
||||
<h2>容器导航</h2>
|
||||
<div class="card-actions icon-links" aria-label="快捷功能">
|
||||
@@ -36,9 +35,6 @@
|
||||
<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() %}
|
||||
@@ -46,45 +42,6 @@
|
||||
{% endfor %}
|
||||
</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>
|
||||
|
||||
<section class="catalog-content">
|
||||
@@ -109,6 +66,9 @@
|
||||
{% 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)">
|
||||
{% 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="text" name="description" placeholder="备注(可选)">
|
||||
<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' %}
|
||||
<p>编号前缀: {{ item.box.slot_prefix }} | 袋装清单不使用范围</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 %}
|
||||
<p>编号前缀: {{ item.box.slot_prefix }} | 范围: {{ item.slot_range }}</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 %}
|
||||
<input type="text" name="name" value="{{ item.base_name }}" 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="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>
|
||||
@@ -182,53 +148,6 @@
|
||||
|
||||
<script>
|
||||
(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) {
|
||||
@@ -273,6 +192,8 @@
|
||||
var boxType = btn.dataset.boxType || 'small_28';
|
||||
var boxId = btn.dataset.boxId || '';
|
||||
var prefix = prefixInput ? prefixInput.value.trim() : '';
|
||||
var slotCapacityInput = form.querySelector('input[name="slot_capacity"]');
|
||||
var slotCapacity = slotCapacityInput ? slotCapacityInput.value.trim() : '';
|
||||
|
||||
var params = new URLSearchParams();
|
||||
params.set('box_type', boxType);
|
||||
@@ -282,6 +203,9 @@
|
||||
if (boxId) {
|
||||
params.set('box_id', boxId);
|
||||
}
|
||||
if (slotCapacity) {
|
||||
params.set('slot_capacity', slotCapacity);
|
||||
}
|
||||
|
||||
fetch('{{ url_for('suggest_start') }}?' + params.toString())
|
||||
.then(function (resp) { return resp.json(); })
|
||||
@@ -297,8 +221,6 @@
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
bindSidebarCompactMode();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -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
109
templates/search.html
Normal 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>
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<nav class="hero-actions">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
|
||||
47
templates/type_edit.html
Normal file
47
templates/type_edit.html
Normal 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>
|
||||
@@ -3,32 +3,83 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h1>分类总览</h1>
|
||||
<p>将容器拆分为独立界面,避免长页面翻找</p>
|
||||
<h1>仓库概览</h1>
|
||||
<p>先看关键指标与待补货,再进入对应分类处理</p>
|
||||
</div>
|
||||
<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" href="{{ url_for('scan_page') }}">扫码/搜索</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
{% for item in type_cards %}
|
||||
<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="hint">{{ item.desc }}</p>
|
||||
<p class="hint">容器 {{ item.count }} 个 | 启用元件 {{ item.item_count }} 种 | 总库存 {{ item.quantity }}</p>
|
||||
<a class="btn" href="{{ item.url }}">进入分类</a>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user