初次提交项目
This commit is contained in:
194
app.py
Normal file
194
app.py
Normal file
@@ -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/<int:box_id>")
|
||||
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/<int:box_id>/<int:slot>", 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)
|
||||
7
init_db.py
Normal file
7
init_db.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from app import app, bootstrap
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
bootstrap()
|
||||
print("数据库初始化完成: data/inventory.db")
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Flask
|
||||
Flask-SQLAlchemy
|
||||
203
static/css/style.css
Normal file
203
static/css/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
20
static/js/scanner.js
Normal file
20
static/js/scanner.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
35
templates/box.html
Normal file
35
templates/box.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ box.name }} - 28宫格</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero slim">
|
||||
<h1>{{ box.name }} - 28 宫格</h1>
|
||||
<nav>
|
||||
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
|
||||
<a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="grid-28">
|
||||
{% for item in slots %}
|
||||
<a class="slot {% if item.component %}filled{% endif %}" href="{{ url_for('edit_component', box_id=box.id, slot=item.slot) }}">
|
||||
<span class="slot-no">#{{ item.slot }}</span>
|
||||
{% if item.component %}
|
||||
<strong>{{ item.component.part_no }}</strong>
|
||||
<small>{{ item.component.name }}</small>
|
||||
<small>库存: {{ item.component.quantity }}</small>
|
||||
{% else %}
|
||||
<small>空位</small>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
55
templates/edit.html
Normal file
55
templates/edit.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!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">
|
||||
<h1>{{ box.name }} - 格子 #{{ slot }}</h1>
|
||||
<a class="btn btn-light" href="{{ url_for('view_box', box_id=box.id) }}">返回宫格</a>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% if error %}
|
||||
<p class="alert">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="panel form-grid" method="post">
|
||||
<label>
|
||||
料号 *
|
||||
<input type="text" name="part_no" required value="{{ component.part_no if component else '' }}">
|
||||
</label>
|
||||
<label>
|
||||
名称 *
|
||||
<input type="text" name="name" required value="{{ component.name if component else '' }}">
|
||||
</label>
|
||||
<label>
|
||||
规格
|
||||
<input type="text" name="specification" value="{{ component.specification if component else '' }}">
|
||||
</label>
|
||||
<label>
|
||||
数量
|
||||
<input type="number" name="quantity" min="0" value="{{ component.quantity if component else 0 }}">
|
||||
</label>
|
||||
<label>
|
||||
位置备注
|
||||
<input type="text" name="location" value="{{ component.location if component else '' }}">
|
||||
</label>
|
||||
<label class="full">
|
||||
备注
|
||||
<textarea name="note" rows="3">{{ component.note if component else '' }}</textarea>
|
||||
</label>
|
||||
|
||||
<div class="actions full">
|
||||
<button class="btn" type="submit" name="action" value="save">保存</button>
|
||||
{% if component %}
|
||||
<button class="btn btn-danger" type="submit" name="action" value="delete" onclick="return confirm('确认清空该格子吗?')">删除</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
31
templates/index.html
Normal file
31
templates/index.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!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">
|
||||
<h1>电子元件库存管理 v1.0</h1>
|
||||
<a class="btn" href="{{ url_for('scan_page') }}">扫码/搜索</a>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<h2>大盒列表</h2>
|
||||
<section class="box-list">
|
||||
{% for item in box_cards %}
|
||||
<article class="box-card">
|
||||
<h3>{{ item.box.name }}</h3>
|
||||
<p>{{ item.box.description or '暂无描述' }}</p>
|
||||
<p>已使用: {{ item.used_count }}/28</p>
|
||||
<a class="btn" href="{{ url_for('view_box', box_id=item.box.id) }}">进入 28 宫格</a>
|
||||
</article>
|
||||
{% else %}
|
||||
<p>暂无大盒数据,请先初始化数据库。</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
61
templates/scan.html
Normal file
61
templates/scan.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!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">
|
||||
<h1>扫码/搜索元件</h1>
|
||||
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="panel">
|
||||
<form method="get" action="{{ url_for('scan_page') }}" class="search-row" id="scan-search-form">
|
||||
<input id="scan-input" type="text" name="q" placeholder="输入或扫码料号/名称" value="{{ keyword }}">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in results %}
|
||||
<tr>
|
||||
<td>{{ c.part_no }}</td>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>{{ c.quantity }}</td>
|
||||
<td>盒 {{ c.box_id }} / 格 {{ c.slot_index }}</td>
|
||||
<td><a 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>
|
||||
Reference in New Issue
Block a user