From 22147a1c03195ad86cf5c824e8198d8264757873 Mon Sep 17 00:00:00 2001 From: wangbeihong Date: Sun, 8 Mar 2026 02:48:24 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=A2=9E=E5=BC=BA=E7=AE=B1?= =?UTF-8?q?=E5=AD=90=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E4=B8=8E=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增箱子重命名与删除功能 - 引入新箱子前缀与起始编号配置 - 在首页展示箱子编号范围 - 添加概览按钮,快速查看已启用的物品及其名称 - 实现组件的启用/禁用功能 - 更新数据库结构,新增箱子与组件字段 - 优化箱子与组件管理界面,改进表单与表格展示 - 在索引页面增加箱子详细信息概览区域 - 增强扫描与搜索功能,优化结果显示效果 --- README.md | 59 +++- app.py | 825 ++++++++++++++++++++++++++----------------- static/css/style.css | 34 +- templates/box.html | 17 +- templates/edit.html | 11 +- templates/index.html | 37 +- templates/scan.html | 7 +- 7 files changed, 656 insertions(+), 334 deletions(-) diff --git a/README.md b/README.md index 58719e2..1b14ae0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ - `14格中盒大盒`:中等盒子,无固定摆放图。 - `袋装清单`:防静电袋列表模式(每行一个袋位,支持批量新增)。 +v1.1 新增能力: + +- 支持盒子改名和删除。 +- 新增盒子时可设置 `前缀 + 起始序号`,内部编号自动递增。 +- 首页可直接看到每个盒子的编号范围(如 `A1-A28`)。 +- 首页新增概览按钮:快速查看已启用的编号与名称。 +- 编辑页支持 `启用/停用`。 + ## 1. 项目结构 ```text @@ -59,15 +67,19 @@ python app.py - 按容器类型显示 3 个列表。 - 每个列表都可新增盒子。 +- 每个盒子可在首页直接改名、修改前缀和起始序号、删除。 +- 每个盒子有概览按钮,快速查看已启用条目。 ### 3.2 盒子详情 `/box/` - `28格/14格`:格子视图,点格子进入编辑。 - `袋装清单`:表格视图,支持单条新增和批量新增。 +- 页面显示自动编号范围(由前缀+起始序号生成)。 ### 3.3 编辑页 `/edit//` - 编辑料号、名称、规格、数量、位置备注、备注。 +- 支持勾选启用,或通过按钮启用/停用。 - 可删除当前格子记录。 ### 3.4 扫码/搜索 `/scan` @@ -96,13 +108,50 @@ python app.py - `数量` 需为大于等于 0 的整数(留空按 0)。 - 无效行会跳过并提示。 -## 5. 数据库说明 +## 5. 自动编号规则(新增) + +新增盒子时只需填写: + +- `前缀`:如 `A`、`B`、`C`、`BAG` +- `起始序号`:如 `1` + +系统自动生成内部编号: + +- 第 1 位:`前缀 + 起始序号` +- 第 N 位:`前缀 + (起始序号 + N - 1)` + +示例: + +- 前缀 `A`、起始 `1`、容量 28 -> `A1-A28` +- 前缀 `B`、起始 `100`、容量 14 -> `B100-B113` + +## 6. 元器件命名建议(简洁版) + +为避免命名过长又保证可检索,建议: + +- `料号(part_no)`:优先写厂家/采购料号,保持唯一。 +- `名称(name)`:`品类 + 关键值 + 封装`,如 `电阻10K 0603`、`电容100nF 0603`。 +- `规格(specification)`:补充精度/耐压/温漂等必要信息,如 `1%`、`50V X7R`。 + +推荐格式: + +```text +名称: 电阻10K 0603 +规格: 1% +``` + +```text +名称: 电容100nF 0603 +规格: 50V X7R +``` + +## 7. 数据库说明 - 使用 SQLite,文件路径:`data/inventory.db` - 首次发布执行一次 `python init_db.py` - 后续通常不需要重复初始化 -## 6. 服务器部署(宝塔) +## 8. 服务器部署(宝塔) 建议流程: @@ -116,7 +165,7 @@ python app.py 建议 Gunicorn 仅监听内网:`127.0.0.1:5000` -## 7. 日常发布流程 +## 9. 日常发布流程 本地开发后: @@ -137,7 +186,7 @@ pip install -r requirements.txt 最后在宝塔里重启 Python 项目。 -## 8. 备份建议 +## 10. 备份建议 重点备份:`data/inventory.db` @@ -147,7 +196,7 @@ pip install -r requirements.txt cp /www/wwwroot/inventory/data/inventory.db /www/backup/inventory_$(date +%F).db ``` -## 9. 常见问题 +## 11. 常见问题 ### Q1: VS Code 提示无法解析 `flask` 导入 diff --git a/app.py b/app.py index d586e07..936cfde 100644 --- a/app.py +++ b/app.py @@ -17,418 +17,611 @@ db = SQLAlchemy(app) BOX_TYPES = { - "small_28": { - "label": "28格小盒大盒", - "default_capacity": 28, - "default_desc": "4连排小盒,常见摆放为竖向7排", - }, - "medium_14": { - "label": "14格中盒大盒", - "default_capacity": 14, - "default_desc": "装十四个中等盒子(尺寸不固定)", - }, - "bag": { - "label": "袋装清单", - "default_capacity": 1, - "default_desc": "用于较小防静电袋存放", - }, + "small_28": { + "label": "28格小盒大盒", + "default_capacity": 28, + "default_desc": "4连排小盒,常见摆放为竖向7排", + "default_prefix": "A", + }, + "medium_14": { + "label": "14格中盒大盒", + "default_capacity": 14, + "default_desc": "装十四个中等盒子(尺寸不固定)", + "default_prefix": "B", + }, + "bag": { + "label": "袋装清单", + "default_capacity": 1, + "default_desc": "用于较小防静电袋存放", + "default_prefix": "BAG", + }, } class Box(db.Model): - __tablename__ = "boxes" + __tablename__ = "boxes" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100), nullable=False, unique=True) - description = db.Column(db.String(255), nullable=True) - box_type = db.Column(db.String(30), nullable=False, default="small_28") - slot_capacity = db.Column(db.Integer, nullable=False, default=28) + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True) + description = db.Column(db.String(255), nullable=True) + box_type = db.Column(db.String(30), nullable=False, default="small_28") + slot_capacity = db.Column(db.Integer, nullable=False, default=28) + slot_prefix = db.Column(db.String(16), nullable=False, default="A") + start_number = db.Column(db.Integer, nullable=False, default=1) class Component(db.Model): - __tablename__ = "components" + __tablename__ = "components" - id = db.Column(db.Integer, primary_key=True) - box_id = db.Column(db.Integer, db.ForeignKey("boxes.id"), nullable=False) - slot_index = db.Column(db.Integer, nullable=False) + id = db.Column(db.Integer, primary_key=True) + box_id = db.Column(db.Integer, db.ForeignKey("boxes.id"), nullable=False) + slot_index = db.Column(db.Integer, nullable=False) - part_no = db.Column(db.String(100), nullable=False) - name = db.Column(db.String(120), nullable=False) - specification = db.Column(db.String(120), nullable=True) - quantity = db.Column(db.Integer, nullable=False, default=0) - location = db.Column(db.String(120), nullable=True) - note = db.Column(db.Text, nullable=True) + part_no = db.Column(db.String(100), nullable=False) + name = db.Column(db.String(120), nullable=False) + specification = db.Column(db.String(120), nullable=True) + quantity = db.Column(db.Integer, nullable=False, default=0) + location = db.Column(db.String(120), nullable=True) + note = db.Column(db.Text, nullable=True) + is_enabled = db.Column(db.Boolean, nullable=False, default=True) - box = db.relationship("Box", backref=db.backref("components", lazy=True)) + box = db.relationship("Box", backref=db.backref("components", lazy=True)) - __table_args__ = ( - db.UniqueConstraint("box_id", "slot_index", name="uq_box_slot"), - ) + __table_args__ = ( + db.UniqueConstraint("box_id", "slot_index", name="uq_box_slot"), + ) -def ensure_default_box() -> None: - defaults = [ - ("默认28格大盒", "small_28"), - ("默认14格中盒", "medium_14"), - ("默认袋装清单", "bag"), - ] - for box_name, box_type in defaults: - exists = Box.query.filter_by(name=box_name).first() - if exists: - continue - meta = BOX_TYPES[box_type] - db.session.add( - Box( - name=box_name, - description=meta["default_desc"], - box_type=box_type, - slot_capacity=meta["default_capacity"], - ) - ) - db.session.commit() +def _add_column_if_missing(table_name: str, column_name: str, ddl: str) -> None: + columns = { + row[1] + for row in db.session.execute(db.text(f"PRAGMA table_info({table_name})")).fetchall() + } + if column_name not in columns: + db.session.execute(db.text(f"ALTER TABLE {table_name} ADD COLUMN {ddl}")) + + +def ensure_schema() -> None: + _add_column_if_missing( + "boxes", + "box_type", + "box_type VARCHAR(30) NOT NULL DEFAULT 'small_28'", + ) + _add_column_if_missing( + "boxes", + "slot_capacity", + "slot_capacity INTEGER NOT NULL DEFAULT 28", + ) + _add_column_if_missing( + "boxes", + "slot_prefix", + "slot_prefix VARCHAR(16) NOT NULL DEFAULT 'A'", + ) + _add_column_if_missing( + "boxes", + "start_number", + "start_number INTEGER NOT NULL DEFAULT 1", + ) + _add_column_if_missing( + "components", + "is_enabled", + "is_enabled BOOLEAN NOT NULL DEFAULT 1", + ) + db.session.commit() + + +def slot_code_for_box(box: Box, slot_index: int) -> str: + serial = box.start_number + slot_index - 1 + return f"{box.slot_prefix}{serial}" + + +def slot_range_label(box: Box) -> str: + start_code = slot_code_for_box(box, 1) + end_code = slot_code_for_box(box, box.slot_capacity) + return f"{start_code}-{end_code}" def slot_data_for_box(box: Box): - components = Component.query.filter_by(box_id=box.id).all() - slot_map = {c.slot_index: c for c in components} - slots = [] - for slot in range(1, box.slot_capacity + 1): - slots.append({"slot": slot, "component": slot_map.get(slot)}) - return slots + components = Component.query.filter_by(box_id=box.id).all() + slot_map = {c.slot_index: c for c in components} + slots = [] + for slot in range(1, box.slot_capacity + 1): + slots.append( + { + "slot": slot, + "slot_code": slot_code_for_box(box, slot), + "component": slot_map.get(slot), + } + ) + return slots -def bag_items_for_box(box: Box): - return ( - Component.query.filter_by(box_id=box.id) - .order_by(Component.slot_index.asc()) - .all() - ) +def bag_rows_for_box(box: Box): + rows = [] + components = ( + Component.query.filter_by(box_id=box.id) + .order_by(Component.slot_index.asc()) + .all() + ) + for c in components: + rows.append({"component": c, "slot_code": slot_code_for_box(box, c.slot_index)}) + return rows def _parse_non_negative_int(raw_value: str, default_value: int = 0) -> int: - raw = (raw_value or "").strip() - if raw == "": - return default_value - value = int(raw) - if value < 0: - raise ValueError - return value + raw = (raw_value or "").strip() + if raw == "": + return default_value + value = int(raw) + if value < 0: + raise ValueError + return value -def _add_bag_item( - box: Box, - part_no: str, - name: str, - quantity: int, - specification: str, - location: str, - note: str, -) -> None: - next_slot = ( - db.session.query(db.func.max(Component.slot_index)) - .filter(Component.box_id == box.id) - .scalar() - or 0 - ) + 1 +def ensure_default_boxes() -> None: + defaults = [ + ("默认28格大盒", "small_28"), + ("默认14格中盒", "medium_14"), + ("默认袋装清单", "bag"), + ] + changed = False + for box_name, box_type in defaults: + if Box.query.filter_by(name=box_name).first(): + continue + meta = BOX_TYPES[box_type] + db.session.add( + Box( + name=box_name, + description=meta["default_desc"], + box_type=box_type, + slot_capacity=meta["default_capacity"], + slot_prefix=meta["default_prefix"], + start_number=1, + ) + ) + changed = True + if changed: + db.session.commit() - item = Component( - box_id=box.id, - slot_index=next_slot, - part_no=part_no, - name=name, - quantity=quantity, - specification=specification or None, - location=location or None, - note=note or None, - ) - db.session.add(item) - if next_slot > box.slot_capacity: - box.slot_capacity = next_slot +def normalize_legacy_data() -> None: + db.session.execute( + db.text( + "UPDATE boxes SET box_type = 'small_28' WHERE box_type IS NULL OR box_type = ''" + ) + ) + db.session.execute( + db.text("UPDATE boxes SET slot_capacity = 28 WHERE slot_capacity IS NULL") + ) + db.session.execute( + db.text("UPDATE boxes SET slot_prefix = 'A' WHERE slot_prefix IS NULL OR slot_prefix = ''") + ) + db.session.execute( + db.text("UPDATE boxes SET start_number = 1 WHERE start_number IS NULL") + ) + db.session.execute( + db.text("UPDATE components SET is_enabled = 1 WHERE is_enabled IS NULL") + ) + + for box in Box.query.all(): + if box.box_type not in BOX_TYPES: + box.box_type = "small_28" + if not box.slot_capacity or box.slot_capacity < 1: + box.slot_capacity = BOX_TYPES[box.box_type]["default_capacity"] + if not box.slot_prefix: + box.slot_prefix = BOX_TYPES[box.box_type]["default_prefix"] + if box.start_number is None or box.start_number < 0: + box.start_number = 1 + + db.session.commit() + + +def make_overview_rows(box: Box): + enabled_components = ( + Component.query.filter_by(box_id=box.id, is_enabled=True) + .order_by(Component.slot_index.asc()) + .all() + ) + rows = [] + for c in enabled_components: + rows.append( + { + "slot_code": slot_code_for_box(box, c.slot_index), + "name": c.name, + "part_no": c.part_no, + } + ) + return rows def render_box_page(box: Box, error: str = "", notice: str = ""): - slots = slot_data_for_box(box) - bag_items = bag_items_for_box(box) if box.box_type == "bag" else [] - return render_template( - "box.html", - box=box, - slots=slots, - bag_items=bag_items, - box_types=BOX_TYPES, - error=error, - notice=notice, - ) - - -def ensure_box_schema() -> None: - columns = { - row[1] - for row in db.session.execute(db.text("PRAGMA table_info(boxes)")).fetchall() - } - if "box_type" not in columns: - db.session.execute( - db.text( - "ALTER TABLE boxes ADD COLUMN box_type VARCHAR(30) NOT NULL DEFAULT 'small_28'" - ) - ) - if "slot_capacity" not in columns: - db.session.execute( - db.text( - "ALTER TABLE boxes ADD COLUMN slot_capacity INTEGER NOT NULL DEFAULT 28" - ) - ) - db.session.commit() + slots = slot_data_for_box(box) + bag_rows = bag_rows_for_box(box) if box.box_type == "bag" else [] + return render_template( + "box.html", + box=box, + slots=slots, + bag_rows=bag_rows, + box_types=BOX_TYPES, + slot_range=slot_range_label(box), + error=error, + notice=notice, + ) @app.route("/") def index(): - boxes = Box.query.order_by(Box.id.asc()).all() - groups = {key: [] for key in BOX_TYPES.keys()} - for box in boxes: - used_count = Component.query.filter_by(box_id=box.id).count() - box_type = box.box_type if box.box_type in BOX_TYPES else "small_28" - groups[box_type].append({"box": box, "used_count": used_count}) - return render_template("index.html", groups=groups, box_types=BOX_TYPES) + boxes = Box.query.order_by(Box.id.asc()).all() + groups = {key: [] for key in BOX_TYPES.keys()} + + for box in boxes: + box_type = box.box_type if box.box_type in BOX_TYPES else "small_28" + overview_rows = make_overview_rows(box) + groups[box_type].append( + { + "box": box, + "used_count": len(overview_rows), + "slot_range": slot_range_label(box), + "overview_rows": overview_rows, + } + ) + + return render_template("index.html", groups=groups, box_types=BOX_TYPES) @app.route("/boxes/create", methods=["POST"]) def create_box(): - box_type = request.form.get("box_type", "small_28").strip() - name = request.form.get("name", "").strip() - description = request.form.get("description", "").strip() + box_type = request.form.get("box_type", "small_28").strip() + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + slot_prefix = request.form.get("slot_prefix", "").strip().upper() - if box_type not in BOX_TYPES: - return "无效盒子类型", 400 - if not name: - return "盒子名称不能为空", 400 + if box_type not in BOX_TYPES: + return "无效盒子类型", 400 + if not name: + return "盒子名称不能为空", 400 + if Box.query.filter_by(name=name).first(): + return "盒子名称已存在,请更换", 400 - if Box.query.filter_by(name=name).first(): - return "盒子名称已存在,请更换", 400 + try: + start_number = _parse_non_negative_int(request.form.get("start_number", "1"), 1) + except ValueError: + return "起始序号必须是大于等于 0 的整数", 400 - meta = BOX_TYPES[box_type] - box = Box( - name=name, - description=description or meta["default_desc"], - box_type=box_type, - slot_capacity=meta["default_capacity"], - ) - db.session.add(box) - db.session.commit() - return redirect(url_for("index")) + meta = BOX_TYPES[box_type] + box = Box( + name=name, + description=description or meta["default_desc"], + box_type=box_type, + slot_capacity=meta["default_capacity"], + slot_prefix=slot_prefix or meta["default_prefix"], + start_number=start_number, + ) + db.session.add(box) + db.session.commit() + return redirect(url_for("index")) + + +@app.route("/boxes//update", methods=["POST"]) +def update_box(box_id: int): + box = Box.query.get_or_404(box_id) + + new_name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + slot_prefix = request.form.get("slot_prefix", "").strip().upper() + + if not new_name: + return "盒子名称不能为空", 400 + + duplicate = Box.query.filter(Box.name == new_name, Box.id != box.id).first() + if duplicate: + return "盒子名称已存在,请更换", 400 + + try: + start_number = _parse_non_negative_int(request.form.get("start_number", "1"), 1) + except ValueError: + return "起始序号必须是大于等于 0 的整数", 400 + + box.name = new_name + box.description = description or BOX_TYPES[box.box_type]["default_desc"] + box.slot_prefix = slot_prefix or BOX_TYPES[box.box_type]["default_prefix"] + box.start_number = start_number + db.session.commit() + return redirect(url_for("index")) + + +@app.route("/boxes//delete", methods=["POST"]) +def delete_box(box_id: int): + box = Box.query.get_or_404(box_id) + Component.query.filter_by(box_id=box.id).delete() + db.session.delete(box) + db.session.commit() + return redirect(url_for("index")) @app.route("/box/") def view_box(box_id: int): - box = Box.query.get_or_404(box_id) - return render_box_page(box) + box = Box.query.get_or_404(box_id) + return render_box_page(box) @app.route("/box//bags/add", methods=["POST"]) def add_bag_item(box_id: int): - box = Box.query.get_or_404(box_id) - if box.box_type != "bag": - return "当前盒子不是袋装清单", 400 + box = Box.query.get_or_404(box_id) + if box.box_type != "bag": + return "当前盒子不是袋装清单", 400 - part_no = request.form.get("part_no", "").strip() - name = request.form.get("name", "").strip() - specification = request.form.get("specification", "").strip() - location = request.form.get("location", "").strip() - note = request.form.get("note", "").strip() + part_no = request.form.get("part_no", "").strip() + name = request.form.get("name", "").strip() + specification = request.form.get("specification", "").strip() + location = request.form.get("location", "").strip() + note = request.form.get("note", "").strip() - if not part_no or not name: - return render_box_page(box, error="袋装新增失败: 料号和名称不能为空") + if not part_no or not name: + return render_box_page(box, error="袋装新增失败: 料号和名称不能为空") - try: - quantity = _parse_non_negative_int(request.form.get("quantity", "0"), 0) - except ValueError: - return render_box_page(box, error="袋装新增失败: 数量必须是大于等于 0 的整数") + try: + quantity = _parse_non_negative_int(request.form.get("quantity", "0"), 0) + except ValueError: + return render_box_page(box, error="袋装新增失败: 数量必须是大于等于 0 的整数") - _add_bag_item(box, part_no, name, quantity, specification, location, note) - db.session.commit() - return render_box_page(box, notice="已新增 1 条袋装记录") + next_slot = ( + db.session.query(db.func.max(Component.slot_index)) + .filter(Component.box_id == box.id) + .scalar() + or 0 + ) + 1 + + item = Component( + box_id=box.id, + slot_index=next_slot, + part_no=part_no, + name=name, + quantity=quantity, + specification=specification or None, + location=location or None, + note=note or None, + is_enabled=True, + ) + db.session.add(item) + + if next_slot > box.slot_capacity: + box.slot_capacity = next_slot + + db.session.commit() + return render_box_page(box, notice="已新增 1 条袋装记录") @app.route("/box//bags/batch", methods=["POST"]) def add_bag_items_batch(box_id: int): - box = Box.query.get_or_404(box_id) - if box.box_type != "bag": - return "当前盒子不是袋装清单", 400 + box = Box.query.get_or_404(box_id) + if box.box_type != "bag": + return "当前盒子不是袋装清单", 400 - raw_lines = request.form.get("lines", "") - lines = [line.strip() for line in raw_lines.splitlines() if line.strip()] - if not lines: - return render_box_page(box, error="批量新增失败: 请至少输入一行") + raw_lines = request.form.get("lines", "") + lines = [line.strip() for line in raw_lines.splitlines() if line.strip()] + if not lines: + return render_box_page(box, error="批量新增失败: 请至少输入一行") - invalid_lines = [] - added_count = 0 - for line_no, line in enumerate(lines, start=1): - parts = [p.strip() for p in re.split(r"[,\t]", line)] - while len(parts) < 6: - parts.append("") + invalid_lines = [] + added_count = 0 + next_slot = ( + db.session.query(db.func.max(Component.slot_index)) + .filter(Component.box_id == box.id) + .scalar() + or 0 + ) + 1 - part_no = parts[0] - name = parts[1] - quantity_raw = parts[2] - specification = parts[3] - location = parts[4] - note = parts[5] + for line_no, line in enumerate(lines, start=1): + parts = [p.strip() for p in re.split(r"[,\t]", line)] + while len(parts) < 6: + parts.append("") - if not part_no or not name: - invalid_lines.append(f"第 {line_no} 行") - continue + part_no = parts[0] + name = parts[1] + quantity_raw = parts[2] + specification = parts[3] + location = parts[4] + note = parts[5] - try: - quantity = _parse_non_negative_int(quantity_raw, 0) - except ValueError: - invalid_lines.append(f"第 {line_no} 行") - continue + if not part_no or not name: + invalid_lines.append(f"第 {line_no} 行") + continue - _add_bag_item(box, part_no, name, quantity, specification, location, note) - added_count += 1 + try: + quantity = _parse_non_negative_int(quantity_raw, 0) + except ValueError: + invalid_lines.append(f"第 {line_no} 行") + continue - if added_count: - db.session.commit() + db.session.add( + Component( + box_id=box.id, + slot_index=next_slot, + part_no=part_no, + name=name, + quantity=quantity, + specification=specification or None, + location=location or None, + note=note or None, + is_enabled=True, + ) + ) + added_count += 1 + next_slot += 1 - if invalid_lines and added_count == 0: - return render_box_page( - box, - error="批量新增失败: 数据格式不正确(请检查: " + ", ".join(invalid_lines) + ")", - ) + if added_count: + box.slot_capacity = max(box.slot_capacity, next_slot - 1) + db.session.commit() - if invalid_lines: - return render_box_page( - box, - notice=f"已新增 {added_count} 条,以下行被跳过: " + ", ".join(invalid_lines), - ) + if invalid_lines and added_count == 0: + return render_box_page( + box, + error="批量新增失败: 数据格式不正确(请检查: " + ", ".join(invalid_lines) + ")", + ) - return render_box_page(box, notice=f"批量新增成功,共 {added_count} 条") + if invalid_lines: + return render_box_page( + box, + notice=f"已新增 {added_count} 条,以下行被跳过: " + ", ".join(invalid_lines), + ) + + return render_box_page(box, notice=f"批量新增成功,共 {added_count} 条") @app.route("/edit//", methods=["GET", "POST"]) def edit_component(box_id: int, slot: int): - box = Box.query.get_or_404(box_id) - if slot < 1 or slot > box.slot_capacity: - return "无效的格子编号", 400 + box = Box.query.get_or_404(box_id) + if slot < 1 or slot > box.slot_capacity: + return "无效的格子编号", 400 - 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": - action = request.form.get("action", "save") + if request.method == "POST": + action = request.form.get("action", "save") - if action == "delete": - if component: - db.session.delete(component) - db.session.commit() - return redirect(url_for("view_box", box_id=box.id)) + if action == "delete": + if component: + db.session.delete(component) + db.session.commit() + return redirect(url_for("view_box", box_id=box.id)) - part_no = request.form.get("part_no", "").strip() - name = request.form.get("name", "").strip() - specification = request.form.get("specification", "").strip() - quantity_raw = request.form.get("quantity", "0").strip() - location = request.form.get("location", "").strip() - note = request.form.get("note", "").strip() + if action == "toggle_enable": + if component: + component.is_enabled = True + db.session.commit() + return redirect(url_for("edit_component", box_id=box.id, slot=slot)) - if not part_no or not name: - error = "料号和名称不能为空" - return render_template( - "edit.html", - box=box, - slot=slot, - component=component, - error=error, - ) + if action == "toggle_disable": + if component: + component.is_enabled = False + db.session.commit() + return redirect(url_for("edit_component", box_id=box.id, slot=slot)) - try: - quantity = int(quantity_raw) - if quantity < 0: - raise ValueError - except ValueError: - error = "数量必须是大于等于 0 的整数" - return render_template( - "edit.html", - box=box, - slot=slot, - component=component, - error=error, - ) + part_no = request.form.get("part_no", "").strip() + name = request.form.get("name", "").strip() + specification = request.form.get("specification", "").strip() + quantity_raw = request.form.get("quantity", "0").strip() + location = request.form.get("location", "").strip() + note = request.form.get("note", "").strip() + is_enabled = request.form.get("is_enabled") == "1" - if component is None: - component = Component(box_id=box.id, slot_index=slot) - db.session.add(component) + if not part_no or not name: + error = "料号和名称不能为空" + return render_template( + "edit.html", + box=box, + slot=slot, + slot_code=slot_code_for_box(box, slot), + component=component, + error=error, + ) - component.part_no = part_no - component.name = name - component.specification = specification or None - component.quantity = quantity - component.location = location or None - component.note = note or None + try: + quantity = _parse_non_negative_int(quantity_raw, 0) + except ValueError: + error = "数量必须是大于等于 0 的整数" + return render_template( + "edit.html", + box=box, + slot=slot, + slot_code=slot_code_for_box(box, slot), + component=component, + error=error, + ) - db.session.commit() - return redirect(url_for("view_box", box_id=box.id)) + if component is None: + component = Component(box_id=box.id, slot_index=slot) + db.session.add(component) - return render_template("edit.html", box=box, slot=slot, component=component) + component.part_no = part_no + component.name = name + component.specification = specification or None + component.quantity = quantity + component.location = location or None + component.note = note or None + component.is_enabled = is_enabled + + db.session.commit() + return redirect(url_for("view_box", box_id=box.id)) + + return render_template( + "edit.html", + box=box, + slot=slot, + slot_code=slot_code_for_box(box, slot), + component=component, + ) @app.route("/scan") def scan_page(): - keyword = request.args.get("q", "").strip() - results = [] - if keyword: - results = ( - Component.query.filter( - db.or_( - Component.part_no.ilike(f"%{keyword}%"), - Component.name.ilike(f"%{keyword}%"), - ) - ) - .order_by(Component.part_no.asc()) - .all() - ) - return render_template("scan.html", keyword=keyword, results=results) + keyword = request.args.get("q", "").strip() + results = [] + + if keyword: + raw_results = ( + Component.query.join(Box, Box.id == Component.box_id) + .filter( + db.or_( + Component.part_no.ilike(f"%{keyword}%"), + Component.name.ilike(f"%{keyword}%"), + ) + ) + .order_by(Component.part_no.asc()) + .all() + ) + + for c in raw_results: + box = Box.query.get(c.box_id) + results.append( + { + "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), + } + ) + + return render_template("scan.html", keyword=keyword, results=results) @app.route("/api/scan") def scan_api(): - code = request.args.get("code", "").strip() - if not code: - return {"ok": False, "message": "code 不能为空"}, 400 + 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 + item = Component.query.filter_by(part_no=code).first() + if not item: + return {"ok": False, "message": "未找到元件"}, 404 - return { - "ok": True, - "data": { - "id": item.id, - "part_no": item.part_no, - "name": item.name, - "quantity": item.quantity, - "box_id": item.box_id, - "slot_index": item.slot_index, - }, - } + 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() - ensure_box_schema() - db.session.execute( - db.text( - "UPDATE boxes SET box_type = 'small_28' WHERE box_type IS NULL OR box_type = ''" - ) - ) - db.session.execute( - db.text("UPDATE boxes SET slot_capacity = 28 WHERE slot_capacity IS NULL") - ) - db.session.commit() - ensure_default_box() + with app.app_context(): + db.create_all() + ensure_schema() + normalize_legacy_data() + ensure_default_boxes() bootstrap() if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000, debug=True) + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/static/css/style.css b/static/css/style.css index 9a84f3f..75c777d 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -100,11 +100,15 @@ body { .new-box-form { display: grid; - grid-template-columns: 1.2fr 1fr auto; + grid-template-columns: 1.2fr 0.8fr 0.7fr 1fr auto; gap: 8px; margin-bottom: 12px; } +.new-box-form.compact { + grid-template-columns: 1.1fr 0.8fr 0.7fr 1fr auto; +} + .box-card, .panel { background: var(--card); @@ -122,6 +126,34 @@ body { margin: 0 0 6px; } +.card-actions { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.card-actions form { + margin: 0; +} + +.box-overview { + margin-top: 8px; + border: 1px dashed var(--line); + border-radius: 10px; + padding: 8px; +} + +.box-overview summary { + cursor: pointer; + color: var(--muted); +} + +.box-overview ul { + margin: 8px 0 0; + padding-left: 18px; +} + .slot-grid { display: grid; grid-template-columns: repeat(7, minmax(90px, 1fr)); diff --git a/templates/box.html b/templates/box.html index e9dfe09..02342f0 100644 --- a/templates/box.html +++ b/templates/box.html @@ -26,27 +26,30 @@ {% if box.box_type == 'bag' %}

袋装记录

-

每行一个袋位,按序号自动编号

+

编号范围: {{ slot_range }} | 每行一个袋位

- + + - {% for c in bag_items %} + {% for row in bag_rows %} + {% set c = row.component %} - + + @@ -105,15 +108,15 @@ {% else %} -

容量: {{ box.slot_capacity }} 位

+

容量: {{ box.slot_capacity }} 位 | 编号范围: {{ slot_range }}

{% for item in slots %} - #{{ item.slot }} + {{ item.slot_code }} {% if item.component %} {{ item.component.part_no }} {{ item.component.name }} - 库存: {{ item.component.quantity }} + 库存: {{ item.component.quantity }} | {% if item.component.is_enabled %}启用{% else %}停用{% endif %} {% else %} 空位 {% endif %} diff --git a/templates/edit.html b/templates/edit.html index dc0e37b..2825b5a 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -8,7 +8,7 @@
-

{{ box.name }} - 格子 #{{ slot }}

+

{{ box.name }} - 编号 {{ slot_code }}

返回宫格
@@ -38,6 +38,10 @@ 位置备注 +
+ - {% for c in results %} + {% for row in results %} + {% set c = row.component %} - + + {% endfor %}
袋位袋位编号 料号 名称 数量状态 规格 位置备注 操作
#{{ c.slot_index }}{{ row.slot_code }} {{ c.part_no }} {{ c.name }} {{ c.quantity }}{% if c.is_enabled %}启用{% else %}停用{% endif %} {{ c.specification or '-' }} {{ c.location or '-' }} 编辑 名称 库存 位置状态 操作
{{ c.part_no }} {{ c.name }} {{ c.quantity }}盒 {{ c.box_id }} / 格 {{ c.slot_index }}{{ row.box_name }} / {{ row.slot_code }}{% if c.is_enabled %}启用{% else %}停用{% endif %} 编辑