feat: 添加低库存预警功能,优化盒子和容器列表界面

This commit is contained in:
2026-03-08 13:00:47 +08:00
parent 99d002e188
commit 859f92cdf0
4 changed files with 167 additions and 97 deletions

32
app.py
View File

@@ -15,6 +15,8 @@ app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db = SQLAlchemy(app)
LOW_STOCK_THRESHOLD = 5
BOX_TYPES = {
"small_28": {
@@ -26,7 +28,7 @@ BOX_TYPES = {
"medium_14": {
"label": "14格中盒大盒",
"default_capacity": 14,
"default_desc": "装十四个中等盒子(尺寸不固定)",
"default_desc": "14格中盒内部摆放方向与28格不同",
"default_prefix": "B",
},
"bag": {
@@ -185,32 +187,6 @@ def _parse_non_negative_int(raw_value: str, default_value: int = 0) -> int:
return value
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()
def normalize_legacy_data() -> None:
db.session.execute(
db.text(
@@ -347,6 +323,7 @@ def render_box_page(box: Box, error: str = "", notice: str = ""):
bag_rows=bag_rows,
box_types=BOX_TYPES,
slot_range=slot_range_label(box),
low_stock_threshold=LOW_STOCK_THRESHOLD,
error=error,
notice=notice,
)
@@ -805,7 +782,6 @@ def bootstrap() -> None:
db.create_all()
ensure_schema()
normalize_legacy_data()
ensure_default_boxes()
bootstrap()

View File

@@ -18,6 +18,7 @@ body {
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: radial-gradient(circle at top right, #defff8, var(--bg) 42%);
color: var(--text);
scroll-behavior: smooth;
}
.hero {
@@ -45,6 +46,52 @@ body {
padding: 0 16px 28px;
}
.layout-shell {
display: grid;
grid-template-columns: 230px minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.catalog-sidebar {
position: sticky;
top: 16px;
}
.catalog-sidebar h2 {
margin-top: 0;
margin-bottom: 10px;
}
.catalog-nav {
display: flex;
flex-direction: column;
gap: 8px;
}
.catalog-nav a {
display: block;
text-decoration: none;
color: var(--text);
background: #f5fafc;
border: 1px solid var(--line);
border-radius: 10px;
padding: 8px 10px;
}
.catalog-nav a:hover {
border-color: #9ccfd1;
background: #ecfbf8;
}
.catalog-content {
min-width: 0;
}
.catalog-content > h2 {
margin-top: 0;
}
.btn {
display: inline-block;
border: 0;
@@ -79,6 +126,7 @@ body {
.group-panel {
margin-bottom: 16px;
scroll-margin-top: 20px;
}
.group-title-row {
@@ -101,13 +149,13 @@ body {
.new-box-form {
display: grid;
grid-template-columns: 1.2fr 0.8fr 0.7fr 1fr auto auto;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.new-box-form.compact {
grid-template-columns: 1.1fr 0.8fr 0.7fr 1fr auto auto;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.new-box-form .suggest-preview {
@@ -115,6 +163,10 @@ body {
margin: 0;
}
.new-box-form button {
width: 100%;
}
.box-card,
.panel {
background: var(--card);
@@ -127,6 +179,7 @@ body {
.box-card {
width: 300px;
min-height: 260px;
overflow: hidden;
}
.box-card h3 {
@@ -171,10 +224,14 @@ body {
gap: 10px;
}
.slot-grid-14 {
.slot-grid-28-fixed {
grid-template-columns: repeat(7, minmax(90px, 1fr));
}
.slot-grid-14-fixed {
grid-template-columns: repeat(2, minmax(160px, 1fr));
}
.slot-grid-bag {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
@@ -197,6 +254,11 @@ body {
background: #ecfbf8;
}
.slot.low-stock {
border-color: #fda4af;
background: #fff1f2;
}
.slot-no {
font-size: 12px;
color: var(--muted);
@@ -291,6 +353,19 @@ td {
align-items: flex-start;
}
.layout-shell {
grid-template-columns: 1fr;
}
.catalog-sidebar {
position: static;
}
.catalog-nav {
flex-direction: row;
flex-wrap: wrap;
}
.box-list {
grid-template-columns: 1fr;
}
@@ -301,7 +376,7 @@ td {
}
.slot-grid,
.slot-grid-14,
.slot-grid-14-fixed,
.slot-grid-bag {
grid-template-columns: repeat(4, minmax(70px, 1fr));
}

View File

@@ -109,14 +109,20 @@
</section>
{% else %}
<p class="group-desc">容量: {{ box.slot_capacity }} 位 | 编号范围: {{ slot_range }}</p>
<section class="slot-grid{% if box.slot_capacity <= 14 %} slot-grid-14{% endif %}{% if box.slot_capacity <= 4 %} slot-grid-bag{% endif %}">
<section class="slot-grid{% if box.box_type == 'small_28' %} slot-grid-28-fixed{% endif %}{% if box.box_type == 'medium_14' %} slot-grid-14-fixed{% endif %}{% if box.slot_capacity <= 4 %} slot-grid-bag{% endif %}">
{% for item in slots %}
<a class="slot {% if item.component %}filled{% endif %}" href="{{ url_for('edit_component', box_id=box.id, slot=item.slot) }}">
<a class="slot {% if item.component %}filled{% endif %}{% if item.component and item.component.quantity < low_stock_threshold %} low-stock{% endif %}" href="{{ url_for('edit_component', box_id=box.id, slot=item.slot) }}">
{% if box.box_type in ['small_28', 'medium_14'] %}
<span class="slot-no">位置 {{ '%02d'|format(item.slot) }}</span>
{% else %}
<span class="slot-no">{{ item.slot_code }}</span>
{% endif %}
{% if item.component %}
<strong>{{ item.component.part_no }}</strong>
<small>{{ item.component.name }}</small>
<small>库存: {{ item.component.quantity }} | {% if item.component.is_enabled %}启用{% else %}停用{% endif %}</small>
<small>数量: {{ item.component.quantity }}</small>
{% if item.component.quantity < low_stock_threshold %}
<small>低库存预警</small>
{% endif %}
{% else %}
<small>空位</small>
{% endif %}

View File

@@ -13,74 +13,87 @@
</header>
<main class="container">
<h2>容器列表</h2>
<div class="layout-shell">
<aside class="catalog-sidebar panel">
<h2>容器导航</h2>
<nav class="catalog-nav">
{% for key, meta in box_types.items() %}
<a href="#group-{{ key }}">{{ meta.label }} ({{ groups[key]|length }})</a>
{% endfor %}
</nav>
</aside>
{% for key, meta in box_types.items() %}
<section class="group-panel panel" id="group-{{ key }}">
<div class="group-title-row">
<h3>{{ meta.label }}</h3>
<span class="group-desc">{{ meta.default_desc }}</span>
</div>
<section class="catalog-content">
<h2>容器列表</h2>
<form class="new-box-form" method="post" action="{{ url_for('create_box') }}">
<input type="hidden" name="box_type" value="{{ key }}">
<input type="text" name="name" placeholder="基础名称(自动拼范围)" required>
<input type="text" name="slot_prefix" placeholder="前缀(如A/B/C)">
<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>
<button class="btn" type="submit">新增盒子</button>
<span class="hint suggest-preview"></span>
</form>
<section class="box-list">
{% for item in groups[key] %}
<article class="box-card">
<h4>{{ item.box.name }}</h4>
<p>{{ item.box.description or '暂无描述' }}</p>
<p>编号前缀: {{ item.box.slot_prefix }} | 范围: {{ item.slot_range }}</p>
<p>已启用: {{ item.used_count }}/{{ item.box.slot_capacity }}</p>
<div class="card-actions">
<a class="btn" href="{{ url_for('view_box', box_id=item.box.id) }}">进入列表</a>
<form method="post" action="{{ url_for('delete_box', box_id=item.box.id) }}" onsubmit="return confirm('确认删除盒子及内部全部记录吗?')">
<button class="btn btn-danger" type="submit">删除</button>
</form>
{% for key, meta in box_types.items() %}
<section class="group-panel panel" id="group-{{ key }}">
<div class="group-title-row">
<h3>{{ meta.label }}</h3>
<span class="group-desc">{{ meta.default_desc }}</span>
</div>
<details class="box-overview">
<summary>概览(已启用序号和名称)</summary>
{% if item.overview_rows %}
<ul>
{% for row in item.overview_rows %}
<li>{{ row.slot_code }} - {{ row.name }} ({{ row.part_no }})</li>
{% endfor %}
</ul>
{% else %}
<p>暂无启用记录</p>
{% endif %}
</details>
<form class="new-box-form" method="post" action="{{ url_for('create_box') }}">
<input type="hidden" name="box_type" value="{{ key }}">
<input type="text" name="name" placeholder="基础名称(自动拼范围)" required>
<input type="text" name="slot_prefix" placeholder="前缀(如A/B/C)">
<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>
<button class="btn" type="submit">新增盒子</button>
<span class="hint suggest-preview"></span>
</form>
<details class="box-overview">
<summary>设置(改名/前缀/起始号)</summary>
<p class="hint">输入基础名称后,系统会自动生成: 基础名称 + 编号范围。</p>
<form class="new-box-form compact" method="post" action="{{ url_for('update_box', box_id=item.box.id) }}">
<input type="text" name="name" value="{{ item.base_name }}" required>
<input type="text" name="slot_prefix" value="{{ item.box.slot_prefix }}" required>
<input type="number" name="start_number" min="0" value="{{ item.box.start_number }}" required>
<input type="text" name="description" value="{{ item.box.description or '' }}">
<button class="btn btn-light suggest-start-btn" type="button" data-box-id="{{ item.box.id }}" data-box-type="{{ item.box.box_type }}">建议起始号</button>
<button class="btn" type="submit">保存设置</button>
<span class="hint suggest-preview"></span>
</form>
</details>
</article>
{% else %}
<p>当前分类还没有盒子,先新增一个。</p>
<section class="box-list">
{% for item in groups[key] %}
<article class="box-card">
<h4>{{ item.box.name }}</h4>
<p>{{ item.box.description or '暂无描述' }}</p>
<p>编号前缀: {{ item.box.slot_prefix }} | 范围: {{ item.slot_range }}</p>
<p>已启用: {{ item.used_count }}/{{ item.box.slot_capacity }}</p>
<div class="card-actions">
<a class="btn" href="{{ url_for('view_box', box_id=item.box.id) }}">进入列表</a>
<form method="post" action="{{ url_for('delete_box', box_id=item.box.id) }}" onsubmit="return confirm('确认删除盒子及内部全部记录吗?')">
<button class="btn btn-danger" type="submit">删除</button>
</form>
</div>
<details class="box-overview">
<summary>概览(已启用序号和名称)</summary>
{% if item.overview_rows %}
<ul>
{% for row in item.overview_rows %}
<li>{{ row.slot_code }} - {{ row.name }} ({{ row.part_no }})</li>
{% endfor %}
</ul>
{% else %}
<p>暂无启用记录</p>
{% endif %}
</details>
<details class="box-overview">
<summary>设置(改名/前缀/起始号)</summary>
<p class="hint">输入基础名称后,系统会自动生成: 基础名称 + 编号范围。</p>
<form class="new-box-form compact" method="post" action="{{ url_for('update_box', box_id=item.box.id) }}">
<input type="text" name="name" value="{{ item.base_name }}" required>
<input type="text" name="slot_prefix" value="{{ item.box.slot_prefix }}" required>
<input type="number" name="start_number" min="0" value="{{ item.box.start_number }}" required>
<input type="text" name="description" value="{{ item.box.description or '' }}">
<button class="btn btn-light suggest-start-btn" type="button" data-box-id="{{ item.box.id }}" data-box-type="{{ item.box.box_type }}">建议起始号</button>
<button class="btn" type="submit">保存设置</button>
<span class="hint suggest-preview"></span>
</form>
</details>
</article>
{% else %}
<p>当前分类还没有盒子,先新增一个。</p>
{% endfor %}
</section>
</section>
{% endfor %}
</section>
</section>
{% endfor %}
</div>
</main>
<script>