diff --git a/app.py b/app.py new file mode 100644 index 0000000..5e77eca --- /dev/null +++ b/app.py @@ -0,0 +1,194 @@ +import os + +from flask import Flask, redirect, render_template, request, url_for +from flask_sqlalchemy import SQLAlchemy + + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_DIR = os.path.join(BASE_DIR, "data") +os.makedirs(DB_DIR, exist_ok=True) +DB_PATH = os.path.join(DB_DIR, "inventory.db") + +app = Flask(__name__) +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +db = SQLAlchemy(app) + + +class Box(db.Model): + __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) + + +class Component(db.Model): + __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) + + 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) + + box = db.relationship("Box", backref=db.backref("components", lazy=True)) + + __table_args__ = ( + db.UniqueConstraint("box_id", "slot_index", name="uq_box_slot"), + ) + + +def ensure_default_box() -> None: + if not Box.query.first(): + default_box = Box(name="默认大盒", description="每盒 28 个格子") + db.session.add(default_box) + db.session.commit() + + +def slot_data_for_box(box_id: int): + 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, 29): + slots.append({"slot": slot, "component": slot_map.get(slot)}) + return slots + + +@app.route("/") +def index(): + boxes = Box.query.order_by(Box.id.asc()).all() + box_cards = [] + for box in boxes: + used_count = Component.query.filter_by(box_id=box.id).count() + box_cards.append({"box": box, "used_count": used_count}) + return render_template("index.html", box_cards=box_cards) + + +@app.route("/box/") +def view_box(box_id: int): + box = Box.query.get_or_404(box_id) + slots = slot_data_for_box(box.id) + return render_template("box.html", box=box, slots=slots) + + +@app.route("/edit//", methods=["GET", "POST"]) +def edit_component(box_id: int, slot: int): + if slot < 1 or slot > 28: + return "无效的格子编号", 400 + + box = Box.query.get_or_404(box_id) + component = Component.query.filter_by(box_id=box.id, slot_index=slot).first() + + 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)) + + 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 not part_no or not name: + error = "料号和名称不能为空" + return render_template( + "edit.html", + box=box, + slot=slot, + component=component, + error=error, + ) + + 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, + ) + + if component is None: + component = Component(box_id=box.id, slot_index=slot) + db.session.add(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 + + db.session.commit() + return redirect(url_for("view_box", box_id=box.id)) + + return render_template("edit.html", box=box, slot=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) + + +@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 + + 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, + }, + } + + +def bootstrap() -> None: + with app.app_context(): + db.create_all() + ensure_default_box() + + +if __name__ == "__main__": + bootstrap() + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..c3af833 --- /dev/null +++ b/init_db.py @@ -0,0 +1,7 @@ +from app import app, bootstrap + + +if __name__ == "__main__": + with app.app_context(): + bootstrap() + print("数据库初始化完成: data/inventory.db") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..93faca4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask +Flask-SQLAlchemy \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..982d833 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,203 @@ +:root { + --bg: #f3f7fb; + --card: #ffffff; + --text: #112031; + --muted: #506070; + --brand: #0c7a6f; + --brand-dark: #07564f; + --danger: #b42318; + --line: #d8e3ec; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + background: radial-gradient(circle at top right, #defff8, var(--bg) 42%); + color: var(--text); +} + +.hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 24px; + background: linear-gradient(125deg, #0c7a6f, #145e9e); + color: #fff; +} + +.hero.slim { + padding: 18px 24px; +} + +.hero h1 { + margin: 0; + font-size: 24px; +} + +.container { + max-width: 1080px; + margin: 18px auto; + padding: 0 16px 28px; +} + +.btn { + display: inline-block; + border: 0; + background: var(--brand); + color: #fff; + text-decoration: none; + border-radius: 10px; + padding: 9px 14px; + cursor: pointer; + font-weight: 600; +} + +.btn:hover { + background: var(--brand-dark); +} + +.btn-light { + background: #eaf2f9; + color: #183247; +} + +.btn-danger { + background: var(--danger); +} + +.box-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; +} + +.box-card, +.panel { + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 14px; + box-shadow: 0 3px 12px rgba(17, 32, 49, 0.05); +} + +.box-card h3 { + margin: 0 0 6px; +} + +.grid-28 { + display: grid; + grid-template-columns: repeat(7, minmax(90px, 1fr)); + gap: 10px; +} + +.slot { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 110px; + padding: 10px; + border-radius: 12px; + text-decoration: none; + border: 1px solid var(--line); + color: var(--text); + background: #ffffff; +} + +.slot.filled { + border-color: #9ccfd1; + background: #ecfbf8; +} + +.slot-no { + font-size: 12px; + color: var(--muted); +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.form-grid label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 14px; +} + +.form-grid .full { + grid-column: 1 / -1; +} + +input, +textarea { + border: 1px solid var(--line); + border-radius: 10px; + padding: 9px 10px; + font: inherit; +} + +.actions { + display: flex; + gap: 8px; +} + +.alert { + background: #fff1f2; + border: 1px solid #fecdd3; + color: #9f1239; + border-radius: 10px; + padding: 10px; +} + +.search-row { + display: flex; + gap: 8px; +} + +.search-row input { + flex: 1; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + border-bottom: 1px solid var(--line); + padding: 8px; + font-size: 14px; +} + +.hint { + color: var(--muted); + margin: 10px 0 0; +} + +@media (max-width: 760px) { + .hero { + flex-direction: column; + align-items: flex-start; + } + + .grid-28 { + grid-template-columns: repeat(4, minmax(70px, 1fr)); + } + + .form-grid { + grid-template-columns: 1fr; + } +} diff --git a/static/js/scanner.js b/static/js/scanner.js new file mode 100644 index 0000000..5fb68a2 --- /dev/null +++ b/static/js/scanner.js @@ -0,0 +1,20 @@ +(function () { + const input = document.getElementById("scan-input"); + const form = document.getElementById("scan-search-form"); + + 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(); + } + }); +})(); diff --git a/templates/box.html b/templates/box.html new file mode 100644 index 0000000..bd20722 --- /dev/null +++ b/templates/box.html @@ -0,0 +1,35 @@ + + + + + + {{ box.name }} - 28宫格 + + + +
+

{{ box.name }} - 28 宫格

+ +
+ +
+
+ {% for item in slots %} + + #{{ item.slot }} + {% if item.component %} + {{ item.component.part_no }} + {{ item.component.name }} + 库存: {{ item.component.quantity }} + {% else %} + 空位 + {% endif %} + + {% endfor %} +
+
+ + diff --git a/templates/edit.html b/templates/edit.html new file mode 100644 index 0000000..dc0e37b --- /dev/null +++ b/templates/edit.html @@ -0,0 +1,55 @@ + + + + + + 编辑元件 + + + +
+

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

+ 返回宫格 +
+ +
+ {% if error %} +

{{ error }}

+ {% endif %} + +
+ + + + + + + +
+ + {% if component %} + + {% endif %} +
+
+
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..2752383 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,31 @@ + + + + + + 库存系统 - 大盒列表 + + + +
+

电子元件库存管理 v1.0

+ 扫码/搜索 +
+ +
+

大盒列表

+
+ {% for item in box_cards %} +
+

{{ item.box.name }}

+

{{ item.box.description or '暂无描述' }}

+

已使用: {{ item.used_count }}/28

+ 进入 28 宫格 +
+ {% else %} +

暂无大盒数据,请先初始化数据库。

+ {% endfor %} +
+
+ + diff --git a/templates/scan.html b/templates/scan.html new file mode 100644 index 0000000..27ed016 --- /dev/null +++ b/templates/scan.html @@ -0,0 +1,61 @@ + + + + + + 扫码/搜索 + + + +
+

扫码/搜索元件

+ 返回首页 +
+ +
+
+
+ + +
+

扫码枪通常会自动输入后回车,可直接触发搜索。

+
+ +
+

搜索结果

+ {% if keyword and results %} +
+ + + + + + + + + + + + {% for c in results %} + + + + + + + + {% endfor %} + +
料号名称库存位置操作
{{ c.part_no }}{{ c.name }}{{ c.quantity }}盒 {{ c.box_id }} / 格 {{ c.slot_index }}编辑
+
+ {% elif keyword %} +

未找到关键词 "{{ keyword }}" 的元件。

+ {% else %} +

请输入关键词开始搜索。

+ {% endif %} +
+
+ + + +