From 0a54bfd5aad79e9b06e841cc3bf698f7a72ca8d2 Mon Sep 17 00:00:00 2001 From: wangbeihong Date: Tue, 10 Mar 2026 01:34:02 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=A2=9E=E5=BC=BA=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=9A=84=E7=94=A8=E6=88=B7=E7=95=8C=E9=9D=A2=E5=92=8C?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 scanner.js 中为用户操作添加了 toast 通知。 - 更新 box.html 以包含额外的导航选项和改进的布局。 - 增强 edit.html,提供更清晰的说明和改进表单的可访问性。 - 修改了 error.html,以提供有关输入错误的用户指导。 - 改进了 index.html,以优化导航并添加了关键指标显示。 - 增强了 scan.html,优化了搜索输入和操作按钮。 - 引入了 stats.html,用于详细的库存统计和趋势。 - 创建了 types.html,用于分类概述库存类型。 --- README.md | 108 ++++- app.py | 908 +++++++++++++++++++++++++++++++++++++++++-- static/css/style.css | 824 +++++++++++++++++++++++++++++++++------ static/js/scanner.js | 25 ++ templates/box.html | 132 +++++-- templates/edit.html | 105 +++-- templates/error.html | 5 +- templates/index.html | 174 ++++++++- templates/scan.html | 17 +- templates/stats.html | 181 +++++++++ templates/types.html | 34 ++ 11 files changed, 2255 insertions(+), 258 deletions(-) create mode 100644 templates/stats.html create mode 100644 templates/types.html diff --git a/README.md b/README.md index 98b0927..eb15245 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - `28格小盒大盒`:常见 4 连排小盒(竖向 7 排)。 - `14格中盒大盒`:中等盒子,无固定摆放图。 - `袋装清单`:防静电袋列表模式(每行一个袋位,支持批量新增)。 +- `袋装清单` 为固定容器(系统内置一个大盒),不需要新增/删除盒子。 v1.1 新增能力: @@ -30,7 +31,8 @@ inventory/ │ ├── index.html │ ├── box.html │ ├── edit.html -│ └── scan.html +│ ├── scan.html +│ └── stats.html └── static/ ├── css/ │ └── style.css @@ -66,16 +68,26 @@ python app.py ### 3.1 首页 `/` -- 按容器类型显示 3 个列表。 -- 每个列表都可新增盒子。 -- 每个盒子可在首页直接改名、修改前缀和起始序号、删除。 -- 每个盒子有概览按钮,快速查看已启用条目。 +- 首页已改为入口跳转到 `分类总览` 页面。 + +### 3.1.1 分类总览 `/types` + +- 展示三类独立界面入口:`28格小盒大盒`、`14格中盒大盒`、`袋装清单`。 +- 每类入口显示当前容器数量,点击进入单独分类页面。 + +### 3.1.2 分类独立页 `/type/` + +- 每个分类使用独立页面,避免容器变多后的长页翻找。 +- 页面内支持新增、设置、删除,并在操作后停留在当前分类页。 ### 3.2 盒子详情 `/box/` - `28格/14格`:格子视图,点格子进入编辑。 - `袋装清单`:表格视图,支持单条新增和批量新增。 -- 页面显示自动编号范围(由前缀+起始序号生成)。 +- `袋装清单` 仅使用编号前缀(如 `BAG`),不设置编号范围。 +- `28格/14格` 支持快速入库:多行粘贴后自动分配空位。 +- 支持按当前盒子导出打标 CSV(仅导出启用记录),可用于热敏打标机导入。 +- 打标 CSV 列名为中英双语格式(如 `料号(part_no)`、`打标文本(label_text)`),便于直接识别。 ### 3.3 编辑页 `/edit//` @@ -88,6 +100,18 @@ python app.py - 可按料号或名称搜索。 - 支持扫码枪输入后回车触发搜索。 +### 3.5 统计页 `/stats` + +- 独立统计页,仅展示核心指标:`库存总量 / 分类占比 / 变动趋势`。 +- 支持 `7天` 与 `30天` 视图切换:`/stats?days=7`、`/stats?days=30`。 +- 支持分类筛选:`/stats?days=30&box_type=small_28`(可选值:`small_28`、`medium_14`、`bag`、`all`)。 +- 趋势图基于库存变动日志实时计算,来源包括:新增、快速入库、启用/停用、删除。 +- 说明:升级前的历史操作不会自动回溯写入日志,趋势从启用该版本后开始逐步真实化。 +- 新增最近操作时间线(最新 20 条),便于追踪库存变化来源。 +- 筛选到单一分类后,指标会切换为更有意义的数据:`分类库存占比 / 周期操作次数 / 活跃天数 / 分类内元件Top`。 +- 支持趋势数据导出 CSV:`/stats/export?days=7&box_type=all`(包含 `daily_delta` 日增减列)。 +- 支持清除统计日志(当前筛选或全部),仅影响统计与趋势,不影响库存数据本体。 + ## 4. 袋装批量新增格式 在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔: @@ -109,7 +133,21 @@ python app.py - `数量` 需为大于等于 0 的整数(留空按 0)。 - 无效行会跳过并提示。 -## 5. 自动编号规则(新增) +## 5. 快速入库(28格/14格) + +在盒子页面使用“快速入库”,每行一条: + +```text +料号, 名称, 数量, 规格, 位置备注, 备注 +``` + +规则: + +- 自动放入当前盒子的空位。 +- 同料号已存在时自动累加数量(不重复占位)。 +- 格子满了会跳过并提示具体行号。 + +## 6. 自动编号规则(新增) 新增盒子时只需填写: @@ -131,7 +169,7 @@ python app.py - 基础名称 `电阻盒` + 范围 `A1-A28` -> `电阻盒 A1-A28` - 若发生重名会自动变为:`电阻盒 A1-A28 #2` -## 6. 元器件命名建议(简洁版) +## 7. 元器件命名建议(简洁版) 为避免命名过长又保证可检索,建议: @@ -151,13 +189,57 @@ python app.py 规格: 50V X7R ``` -## 7. 数据库说明 +### 7.1 轻量入库规范(推荐) + +目标:字段尽量少,但保证后续能搜索、能区分、能补货。 + +最少必填(3项): + +- `料号(part_no)`:优先填厂家型号(如 `STM32F103C8T6`)。 +- `名称(name)`:`品类 + 型号/关键值`(如 `MCU STM32F103C8T6`)。 +- `数量(quantity)`:当前实际库存数量。 + +建议选填(2-3项): + +- `规格(specification)`:只写 2-4 个关键参数(如 `Cortex-M3 / 64KB Flash / LQFP-48`)。 +- `位置备注(location)`:盒位或袋位(如 `A12`、`BAG4`)。 +- `备注(note)`:来源或追溯信息(如 `LCSC item 9243` 或商品链接)。 + +不建议录入(避免复杂且易过期): + +- 实时单价、促销价、交期、商城库存等动态信息。 + +推荐录入模板: + +```text +料号: <厂家型号> +名称: <品类 + 型号/关键值> +规格: <封装 + 关键参数> +数量: <整数> +位置备注: <盒位/袋位> +备注: <来源编号或链接> +``` + +示例: + +```text +料号: STM32F103C8T6 +名称: MCU STM32F103C8T6 +规格: Cortex-M3 / 64KB Flash / LQFP-48 +数量: 10 +位置备注: BAG4 +备注: LCSC item 9243 +``` + +## 8. 数据库说明 - 使用 SQLite,文件路径:`data/inventory.db` +- 库存变动日志表:`inventory_events`(用于统计页趋势计算) +- `inventory_events` 主要字段:`box_type`、`part_no`、`event_type`、`delta`、`created_at` - 首次发布执行一次 `python init_db.py` - 后续通常不需要重复初始化 -## 8. 服务器部署(宝塔) +## 9. 服务器部署(宝塔) 建议流程: @@ -171,7 +253,7 @@ python app.py 建议 Gunicorn 仅监听内网:`127.0.0.1:5000` -## 9. 日常发布流程 +## 10. 日常发布流程 本地开发后: @@ -192,7 +274,7 @@ pip install -r requirements.txt 最后在宝塔里重启 Python 项目。 -## 10. 备份建议 +## 11. 备份建议 重点备份:`data/inventory.db` @@ -202,7 +284,7 @@ pip install -r requirements.txt cp /www/wwwroot/inventory/data/inventory.db /www/backup/inventory_$(date +%F).db ``` -## 11. 常见问题 +## 12. 常见问题 ### Q1: VS Code 提示无法解析 `flask` 导入 diff --git a/app.py b/app.py index 225436d..a5a5f5b 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,10 @@ import os import re +import csv +from io import StringIO +from datetime import datetime, timedelta -from flask import Flask, redirect, render_template, request, url_for +from flask import Flask, Response, redirect, render_template, request, url_for from flask_sqlalchemy import SQLAlchemy @@ -34,7 +37,7 @@ BOX_TYPES = { "bag": { "label": "袋装清单", "default_capacity": 1, - "default_desc": "用于较小防静电袋存放", + "default_desc": "一袋一种器件,按清单管理", "default_prefix": "BAG", }, } @@ -74,6 +77,19 @@ class Component(db.Model): ) +class InventoryEvent(db.Model): + __tablename__ = "inventory_events" + + id = db.Column(db.Integer, primary_key=True) + box_id = db.Column(db.Integer, nullable=True) + box_type = db.Column(db.String(30), nullable=True) + component_id = db.Column(db.Integer, nullable=True) + part_no = db.Column(db.String(100), nullable=True) + event_type = db.Column(db.String(30), nullable=False) + delta = db.Column(db.Integer, nullable=False, default=0) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now()) + + def _add_column_if_missing(table_name: str, column_name: str, ddl: str) -> None: columns = { row[1] @@ -118,6 +134,8 @@ def slot_code_for_box(box: Box, slot_index: int) -> str: def slot_range_label(box: Box) -> str: + if box.box_type == "bag": + return box.slot_prefix or BOX_TYPES["bag"]["default_prefix"] start_code = slot_code_for_box(box, 1) end_code = slot_code_for_box(box, box.slot_capacity) return f"{start_code}-{end_code}" @@ -215,6 +233,24 @@ def normalize_legacy_data() -> None: 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 + if box.box_type == "bag": + # Bag list is prefix-based and does not use range numbering. + box.start_number = 1 + box.name = f"袋装清单 {box.slot_prefix}" + + # Keep bag list as a fixed container; create one if missing. + if not Box.query.filter_by(box_type="bag").first(): + default_meta = BOX_TYPES["bag"] + db.session.add( + Box( + name=f"袋装清单 {default_meta['default_prefix']}", + description=default_meta["default_desc"], + box_type="bag", + slot_capacity=default_meta["default_capacity"], + slot_prefix=default_meta["default_prefix"], + start_number=1, + ) + ) db.session.commit() @@ -296,10 +332,10 @@ def build_index_anchor(box_type: str = "") -> str: def bad_request(message: str, box_type: str = ""): anchor = build_index_anchor(box_type) - if anchor: - back_url = f"{url_for('index')}#{anchor}" + if anchor and box_type in BOX_TYPES: + back_url = url_for("type_page", box_type=box_type) else: - back_url = request.referrer or url_for("index") + back_url = request.referrer or url_for("types_page") return ( render_template( @@ -329,9 +365,189 @@ def render_box_page(box: Box, error: str = "", notice: str = ""): ) -@app.route("/") -def index(): +def _next_empty_slot_index(box: Box, occupied_slots: set[int]): + if box.box_type == "bag": + return (max(occupied_slots) if occupied_slots else 0) + 1 + + for idx in range(1, box.slot_capacity + 1): + if idx not in occupied_slots: + return idx + return None + + +def _parse_bulk_line(line: str): + parts = [p.strip() for p in re.split(r"[,\t]", line)] + while len(parts) < 5: + parts.append("") + return { + "part_no": parts[0], + "name": parts[1], + "quantity_raw": parts[2], + "specification": parts[3], + "note": parts[4], + } + + +def log_inventory_event( + *, + event_type: str, + delta: int, + box: Box = None, + component: Component = None, + part_no: str = "", +): + event = InventoryEvent( + box_id=box.id if box else (component.box_id if component else None), + box_type=box.box_type if box else None, + component_id=component.id if component else None, + part_no=(part_no or (component.part_no if component else "") or "").strip() or None, + event_type=event_type, + delta=int(delta), + ) + db.session.add(event) + + +def parse_days_value(raw_days: str) -> int: + try: + days = int((raw_days or "7").strip()) + except ValueError: + days = 7 + return days if days in (7, 30) else 7 + + +def parse_box_type_filter(raw_box_type: str) -> str: + box_type = (raw_box_type or "").strip() + return box_type if box_type in BOX_TYPES else "all" + + +def _to_date(raw_day): + if isinstance(raw_day, str): + return datetime.strptime(raw_day, "%Y-%m-%d").date() + return raw_day + + +def query_event_daily_delta(days: int, box_type_filter: str = "all"): + today = datetime.now().date() + start_day = today - timedelta(days=days - 1) + + query = db.session.query( + db.func.date(InventoryEvent.created_at).label("event_day"), + db.func.sum(InventoryEvent.delta).label("daily_delta"), + ).filter(InventoryEvent.created_at >= datetime.combine(start_day, datetime.min.time())) + + if box_type_filter != "all": + query = query.filter(InventoryEvent.box_type == box_type_filter) + + rows = query.group_by(db.func.date(InventoryEvent.created_at)).all() + + delta_by_day = {} + for row in rows: + delta_by_day[_to_date(row.event_day)] = int(row.daily_delta or 0) + + return delta_by_day + + +def build_trend_points_from_events(days: int, total_quantity: int, box_type_filter: str = "all"): + safe_days = days if days in (7, 30) else 7 + today = datetime.now().date() + delta_by_day = query_event_daily_delta(safe_days, box_type_filter) + + points = [] + running_total = total_quantity + reverse_days = [today - timedelta(days=offset) for offset in range(safe_days)] + + for day in reverse_days: + points.append( + { + "date": day, + "label": day.strftime("%m-%d"), + "value": running_total, + } + ) + running_total -= delta_by_day.get(day, 0) + + points.reverse() + return points + + +def build_box_type_trend_series(days: int, totals_by_type: dict): + safe_days = days if days in (7, 30) else 7 + today = datetime.now().date() + all_days = [today - timedelta(days=offset) for offset in range(safe_days)] + + series = {} + for box_type in BOX_TYPES.keys(): + delta_by_day = query_event_daily_delta(safe_days, box_type) + running_total = int(totals_by_type.get(box_type, 0)) + values = [] + for day in all_days: + values.append(running_total) + running_total -= delta_by_day.get(day, 0) + values.reverse() + series[box_type] = values + + return { + "labels": [day.strftime("%m-%d") for day in reversed(all_days)], + "series": series, + } + + +def make_sparkline(values: list[int], width: int = 220, height: int = 56) -> str: + if not values: + return "" + min_value = min(values) + max_value = max(values) + span = max(max_value - min_value, 1) + step_x = width / max(len(values) - 1, 1) + points = [] + + for idx, value in enumerate(values): + x = idx * step_x + y = height - ((value - min_value) / span) * height + points.append(f"{x:.2f},{y:.2f}") + return " ".join(points) + + +def event_type_label(event_type: str) -> str: + labels = { + "quick_inbound_add": "快速入库新增", + "quick_inbound_merge": "快速入库合并", + "component_save": "编辑保存", + "component_enable": "启用元件", + "component_disable": "停用元件", + "component_delete": "删除元件", + "bag_add": "袋装新增", + "bag_batch_add": "袋装批量新增", + "bag_merge": "袋装合并", + "bag_batch_merge": "袋装批量合并", + "box_delete": "删除盒子", + } + return labels.get(event_type, event_type) + + +def recent_events(limit: int = 20, box_type_filter: str = "all"): + query = InventoryEvent.query + if box_type_filter != "all": + query = query.filter_by(box_type=box_type_filter) + + rows = query.order_by(InventoryEvent.created_at.desc()).limit(limit).all() + items = [] + for row in rows: + items.append( + { + "time": row.created_at.strftime("%Y-%m-%d %H:%M") if row.created_at else "-", + "type": event_type_label(row.event_type), + "box_type": BOX_TYPES.get(row.box_type, {}).get("label", "全部"), + "part_no": row.part_no or "-", + "delta": int(row.delta or 0), + } + ) + return items + + +def build_dashboard_context(): boxes = Box.query.all() + box_by_id = {box.id: box for box in boxes} boxes.sort(key=box_sort_key) groups = {key: [] for key in BOX_TYPES.keys()} @@ -348,7 +564,111 @@ def index(): } ) - return render_template("index.html", groups=groups, box_types=BOX_TYPES) + components = Component.query.all() + enabled_components = [c for c in components if c.is_enabled] + disabled_components = [c for c in components if not c.is_enabled] + low_stock_components = [c for c in enabled_components if c.quantity < LOW_STOCK_THRESHOLD] + + trend_points_7d = build_trend_points_from_events( + days=7, + total_quantity=sum(c.quantity for c in enabled_components), + box_type_filter="all", + ) + period_net_change_7d = 0 + if len(trend_points_7d) >= 2: + period_net_change_7d = trend_points_7d[-1]["value"] - trend_points_7d[0]["value"] + + 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) + low_stock_items.append( + { + "name": c.name, + "part_no": c.part_no, + "quantity": c.quantity, + "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), + } + ) + + category_stats = [] + max_category_quantity = 0 + for key, meta in BOX_TYPES.items(): + category_components = [ + c + for c in enabled_components + if box_by_id.get(c.box_id) and box_by_id[c.box_id].box_type == key + ] + quantity = sum(c.quantity for c in category_components) + max_category_quantity = max(max_category_quantity, quantity) + category_stats.append( + { + "key": key, + "label": meta["label"], + "item_count": len(category_components), + "quantity": quantity, + } + ) + + stats = { + "box_count": len(boxes), + "active_items": len(enabled_components), + "low_stock_count": len(low_stock_components), + "disabled_count": len(disabled_components), + "max_category_quantity": max_category_quantity, + "period_net_change_7d": period_net_change_7d, + } + + return { + "groups": groups, + "stats": stats, + "category_stats": category_stats, + "low_stock_items": low_stock_items, + } + + +@app.route("/") +def index(): + return redirect(url_for("types_page")) + + +@app.route("/types") +def types_page(): + dashboard = build_dashboard_context() + type_cards = [] + for key, meta in BOX_TYPES.items(): + type_cards.append( + { + "key": key, + "label": meta["label"], + "desc": meta["default_desc"], + "count": len(dashboard["groups"].get(key, [])), + "url": url_for("type_page", box_type=key), + } + ) + + return render_template("types.html", type_cards=type_cards) + + +@app.route("/type/") +def type_page(box_type: str): + if box_type not in BOX_TYPES: + return bad_request("无效盒子类型", "") + + dashboard = build_dashboard_context() + + return render_template( + "index.html", + groups=dashboard["groups"], + box_types=BOX_TYPES, + stats=dashboard["stats"], + category_stats=dashboard["category_stats"], + low_stock_items=dashboard["low_stock_items"], + view_box_types=[box_type], + current_box_type=box_type, + separate_mode=True, + ) @app.route("/boxes/create", methods=["POST"]) @@ -360,6 +680,8 @@ def create_box(): if box_type not in BOX_TYPES: return bad_request("无效盒子类型", box_type) + if box_type == "bag" and Box.query.filter_by(box_type="bag").first(): + return bad_request("袋装清单为固定容器,无需新增盒子", box_type) if not base_name: return bad_request("盒子名称不能为空", box_type) @@ -402,7 +724,9 @@ def create_box(): ) db.session.add(box) db.session.commit() - return redirect(url_for("index")) + 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_type + return redirect(url_for("type_page", box_type=target_type)) @app.route("/boxes//update", methods=["POST"]) @@ -449,16 +773,31 @@ def update_box(box_id: int): box.slot_prefix = effective_prefix box.start_number = start_number db.session.commit() - return redirect(url_for("index")) + 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 + return redirect(url_for("type_page", box_type=target_type)) @app.route("/boxes//delete", methods=["POST"]) def delete_box(box_id: int): box = Box.query.get_or_404(box_id) + if box.box_type == "bag": + return bad_request("袋装清单为固定容器,不能删除", box.box_type) + enabled_sum = ( + db.session.query(db.func.sum(Component.quantity)) + .filter_by(box_id=box.id, is_enabled=True) + .scalar() + or 0 + ) + if enabled_sum: + log_inventory_event(event_type="box_delete", delta=-int(enabled_sum), box=box) Component.query.filter_by(box_id=box.id).delete() + box_type = box.box_type db.session.delete(box) db.session.commit() - return redirect(url_for("index")) + 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_type + return redirect(url_for("type_page", box_type=target_type)) @app.route("/boxes/suggest-start") @@ -510,6 +849,162 @@ def view_box(box_id: int): return render_box_page(box) +@app.route("/box//labels/export") +def export_box_labels_csv(box_id: int): + box = Box.query.get_or_404(box_id) + rows = ( + Component.query.filter_by(box_id=box.id, is_enabled=True) + .order_by(Component.slot_index.asc()) + .all() + ) + + output = StringIO() + writer = csv.writer(output) + writer.writerow( + [ + "盒子名称(box_name)", + "位置编号(slot_code)", + "料号(part_no)", + "名称(name)", + "规格(specification)", + "数量(quantity)", + "位置备注(location)", + "备注(note)", + ] + ) + + for c in rows: + slot_code = slot_code_for_box(box, c.slot_index) + writer.writerow( + [ + box.name, + slot_code, + c.part_no or "", + c.name or "", + c.specification or "", + int(c.quantity or 0), + c.location or "", + c.note or "", + ] + ) + + csv_content = "\ufeff" + output.getvalue() + output.close() + filename = f"labels_box_{box.id}.csv" + + return Response( + csv_content, + mimetype="text/csv; charset=utf-8", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@app.route("/box//quick-inbound", methods=["POST"]) +def quick_inbound(box_id: int): + box = Box.query.get_or_404(box_id) + 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="快速入库失败: 请至少输入一行") + + components = Component.query.filter_by(box_id=box.id).all() + occupied_slots = {c.slot_index for c in components} + by_part_no = {c.part_no: c for c in components if c.part_no} + + added_count = 0 + merged_count = 0 + skipped_lines = [] + changed = False + + for line_no, line in enumerate(lines, start=1): + parsed = _parse_bulk_line(line) + part_no = parsed["part_no"] + name = parsed["name"] + quantity_raw = parsed["quantity_raw"] + specification = parsed["specification"] + note = parsed["note"] + + if not part_no or not name: + skipped_lines.append(f"第 {line_no} 行") + continue + + try: + quantity = _parse_non_negative_int(quantity_raw, 0) + except ValueError: + skipped_lines.append(f"第 {line_no} 行") + continue + + existing = by_part_no.get(part_no) + if existing: + old_enabled_qty = existing.quantity if existing.is_enabled else 0 + existing.quantity += quantity + existing.name = name or existing.name + if specification: + existing.specification = specification + if note: + existing.note = note + existing.is_enabled = True + new_enabled_qty = existing.quantity + delta = new_enabled_qty - old_enabled_qty + if delta: + log_inventory_event( + event_type="quick_inbound_merge", + delta=delta, + box=box, + component=existing, + part_no=part_no, + ) + merged_count += 1 + changed = True + continue + + slot_index = _next_empty_slot_index(box, occupied_slots) + if slot_index is None: + skipped_lines.append(f"第 {line_no} 行(盒子已满)") + continue + + item = Component( + box_id=box.id, + slot_index=slot_index, + part_no=part_no, + name=name, + quantity=quantity, + specification=specification or None, + note=note or None, + is_enabled=True, + ) + db.session.add(item) + if quantity: + log_inventory_event( + event_type="quick_inbound_add", + delta=quantity, + box=box, + component=item, + part_no=part_no, + ) + occupied_slots.add(slot_index) + by_part_no[part_no] = item + added_count += 1 + changed = True + + if box.box_type == "bag" and occupied_slots: + box.slot_capacity = max(box.slot_capacity, max(occupied_slots)) + + if changed: + db.session.commit() + + if added_count == 0 and merged_count == 0: + return render_box_page( + box, + error="快速入库失败: 没有可导入的数据,请检查格式", + ) + + message = f"快速入库完成: 新增 {added_count} 条,合并 {merged_count} 条" + if skipped_lines: + message += ";跳过: " + ", ".join(skipped_lines) + return render_box_page(box, notice=message) + + @app.route("/box//bags/add", methods=["POST"]) def add_bag_item(box_id: int): box = Box.query.get_or_404(box_id) @@ -519,7 +1014,6 @@ def add_bag_item(box_id: int): 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: @@ -530,6 +1024,29 @@ def add_bag_item(box_id: int): except ValueError: return render_box_page(box, error="袋装新增失败: 数量必须是大于等于 0 的整数") + existing = Component.query.filter_by(box_id=box.id, part_no=part_no).first() + if existing: + old_enabled_qty = existing.quantity if existing.is_enabled else 0 + existing.name = name or existing.name + existing.quantity += quantity + existing.specification = specification or existing.specification + existing.note = note or existing.note + existing.is_enabled = True + + new_enabled_qty = existing.quantity + delta = int(new_enabled_qty - old_enabled_qty) + if delta: + log_inventory_event( + event_type="bag_merge", + delta=delta, + box=box, + component=existing, + part_no=part_no, + ) + + db.session.commit() + return render_box_page(box, notice="同料号已存在,已合并到原袋位") + next_slot = ( db.session.query(db.func.max(Component.slot_index)) .filter(Component.box_id == box.id) @@ -544,11 +1061,18 @@ def add_bag_item(box_id: int): name=name, quantity=quantity, specification=specification or None, - location=location or None, note=note or None, is_enabled=True, ) db.session.add(item) + if quantity: + log_inventory_event( + event_type="bag_add", + delta=quantity, + box=box, + component=item, + part_no=part_no, + ) if next_slot > box.slot_capacity: box.slot_capacity = next_slot @@ -576,18 +1100,21 @@ def add_bag_items_batch(box_id: int): .scalar() or 0 ) + 1 + existing_by_part_no = { + c.part_no: c for c in Component.query.filter_by(box_id=box.id).all() if c.part_no + } + merged_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: + while len(parts) < 5: parts.append("") part_no = parts[0] name = parts[1] quantity_raw = parts[2] specification = parts[3] - location = parts[4] - note = parts[5] + note = parts[4] if not part_no or not name: invalid_lines.append(f"第 {line_no} 行") @@ -599,19 +1126,47 @@ def add_bag_items_batch(box_id: int): invalid_lines.append(f"第 {line_no} 行") continue - 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, - ) + existing = existing_by_part_no.get(part_no) + if existing: + old_enabled_qty = existing.quantity if existing.is_enabled else 0 + existing.name = name or existing.name + existing.quantity += quantity + existing.specification = specification or existing.specification + existing.note = note or existing.note + existing.is_enabled = True + + delta = int(existing.quantity - old_enabled_qty) + if delta: + log_inventory_event( + event_type="bag_batch_merge", + delta=delta, + box=box, + component=existing, + part_no=part_no, + ) + merged_count += 1 + continue + + new_component = Component( + box_id=box.id, + slot_index=next_slot, + part_no=part_no, + name=name, + quantity=quantity, + specification=specification or None, + note=note or None, + is_enabled=True, ) + db.session.add(new_component) + existing_by_part_no[part_no] = new_component + if quantity: + log_inventory_event( + event_type="bag_batch_add", + delta=quantity, + box=box, + component=new_component, + part_no=part_no, + ) added_count += 1 next_slot += 1 @@ -619,7 +1174,7 @@ def add_bag_items_batch(box_id: int): box.slot_capacity = max(box.slot_capacity, next_slot - 1) db.session.commit() - if invalid_lines and added_count == 0: + if invalid_lines and added_count == 0 and merged_count == 0: return render_box_page( box, error="批量新增失败: 数据格式不正确(请检查: " + ", ".join(invalid_lines) + ")", @@ -628,10 +1183,10 @@ def add_bag_items_batch(box_id: int): if invalid_lines: return render_box_page( box, - notice=f"已新增 {added_count} 条,以下行被跳过: " + ", ".join(invalid_lines), + notice=f"已新增 {added_count} 条,合并 {merged_count} 条,以下行被跳过: " + ", ".join(invalid_lines), ) - return render_box_page(box, notice=f"批量新增成功,共 {added_count} 条") + return render_box_page(box, notice=f"批量新增成功:新增 {added_count} 条,合并 {merged_count} 条") @app.route("/edit//", methods=["GET", "POST"]) @@ -647,29 +1202,50 @@ def edit_component(box_id: int, slot: int): if action == "delete": if component: + if component.is_enabled and component.quantity: + log_inventory_event( + event_type="component_delete", + delta=-int(component.quantity), + box=box, + component=component, + ) db.session.delete(component) db.session.commit() return redirect(url_for("view_box", box_id=box.id)) if action == "toggle_enable": if component: - component.is_enabled = True - db.session.commit() + if not component.is_enabled: + component.is_enabled = True + if component.quantity: + log_inventory_event( + event_type="component_enable", + delta=int(component.quantity), + box=box, + component=component, + ) + db.session.commit() return redirect(url_for("edit_component", box_id=box.id, slot=slot)) if action == "toggle_disable": if component: - component.is_enabled = False - db.session.commit() + if component.is_enabled: + component.is_enabled = False + if component.quantity: + log_inventory_event( + event_type="component_disable", + delta=-int(component.quantity), + box=box, + component=component, + ) + db.session.commit() return redirect(url_for("edit_component", box_id=box.id, slot=slot)) 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 not part_no or not name: error = "料号和名称不能为空" @@ -695,6 +1271,10 @@ def edit_component(box_id: int, slot: int): error=error, ) + old_enabled_qty = 0 + if component is not None and component.is_enabled: + old_enabled_qty = int(component.quantity) + if component is None: component = Component(box_id=box.id, slot_index=slot) db.session.add(component) @@ -703,9 +1283,20 @@ def edit_component(box_id: int, slot: int): 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 + if component.is_enabled is None: + component.is_enabled = True + + new_enabled_qty = quantity if component.is_enabled else 0 + delta = int(new_enabled_qty - old_enabled_qty) + if delta: + log_inventory_event( + event_type="component_save", + delta=delta, + box=box, + component=component, + part_no=part_no, + ) db.session.commit() return redirect(url_for("view_box", box_id=box.id)) @@ -750,6 +1341,245 @@ def scan_page(): return render_template("scan.html", keyword=keyword, results=results) +@app.route("/stats") +def stats_page(): + days = parse_days_value(request.args.get("days", "7")) + box_type_filter = parse_box_type_filter(request.args.get("box_type", "all")) + notice = request.args.get("notice", "").strip() + + boxes = Box.query.all() + box_by_id = {box.id: box for box in boxes} + components = Component.query.all() + + all_enabled_components = [c for c in components if c.is_enabled] + overall_total_quantity = sum(c.quantity for c in all_enabled_components) + + enabled_components = [ + c + for c in components + if c.is_enabled and ( + box_type_filter == "all" + or (box_by_id.get(c.box_id) and box_by_id[c.box_id].box_type == box_type_filter) + ) + ] + total_quantity = sum(c.quantity for c in enabled_components) + total_items = len(enabled_components) + low_stock_count = len([c for c in enabled_components if c.quantity < LOW_STOCK_THRESHOLD]) + inventory_share = ( + round(total_quantity * 100 / overall_total_quantity, 1) if overall_total_quantity > 0 else 0.0 + ) + + start_day = datetime.now().date() - timedelta(days=days - 1) + event_query = InventoryEvent.query.filter( + InventoryEvent.created_at >= datetime.combine(start_day, datetime.min.time()) + ) + if box_type_filter != "all": + event_query = event_query.filter_by(box_type=box_type_filter) + period_operation_count = event_query.count() + + active_days = len( + { + _to_date(row[0]) + for row in event_query.with_entities(db.func.date(InventoryEvent.created_at)).all() + if row and row[0] + } + ) + + totals_by_type = {} + for box_type in BOX_TYPES.keys(): + totals_by_type[box_type] = sum( + c.quantity + for c in components + if c.is_enabled and box_by_id.get(c.box_id) and box_by_id[c.box_id].box_type == box_type + ) + + category_stats = [] + for key, meta in BOX_TYPES.items(): + category_components = [ + c + for c in enabled_components + if box_by_id.get(c.box_id) and box_by_id[c.box_id].box_type == key + ] + category_stats.append( + { + "key": key, + "label": meta["label"], + "item_count": len(category_components), + "quantity": sum(c.quantity for c in category_components), + } + ) + + category_stats.sort(key=lambda row: row["quantity"], reverse=True) + max_category_quantity = max([row["quantity"] for row in category_stats], default=0) + + chart_mode = "category" + chart_title = "分类占比" + chart_rows = category_stats + max_chart_quantity = max_category_quantity + + if box_type_filter != "all": + chart_mode = "component" + chart_title = "分类内元件库存 Top" + bucket = {} + for c in enabled_components: + key = (c.part_no or "", c.name or "") + if key not in bucket: + bucket[key] = 0 + bucket[key] += int(c.quantity or 0) + + component_rows = [] + for (part_no, name), quantity in bucket.items(): + label = name or part_no or "未命名元件" + if part_no: + label = f"{label} ({part_no})" + component_rows.append({"label": label, "quantity": quantity, "item_count": 1}) + + component_rows.sort(key=lambda row: row["quantity"], reverse=True) + chart_rows = component_rows[:8] + max_chart_quantity = max([row["quantity"] for row in chart_rows], default=0) + + trend_points = build_trend_points_from_events( + days=days, + total_quantity=total_quantity, + box_type_filter=box_type_filter, + ) + period_net_change = 0 + if len(trend_points) >= 2: + period_net_change = trend_points[-1]["value"] - trend_points[0]["value"] + + min_value = min([point["value"] for point in trend_points], default=0) + max_value = max([point["value"] for point in trend_points], default=0) + value_span = max(max_value - min_value, 1) + + svg_points = [] + if trend_points: + width = 520 + height = 180 + step_x = width / max(len(trend_points) - 1, 1) + for idx, point in enumerate(trend_points): + x = idx * step_x + y = height - ((point["value"] - min_value) / value_span) * height + svg_points.append(f"{x:.2f},{y:.2f}") + + box_type_series_raw = build_box_type_trend_series(days=days, totals_by_type=totals_by_type) + box_type_series = [] + for key, meta in BOX_TYPES.items(): + values = box_type_series_raw["series"].get(key, []) + delta = values[-1] - values[0] if len(values) >= 2 else 0 + box_type_series.append( + { + "key": key, + "label": meta["label"], + "sparkline": make_sparkline(values), + "latest": values[-1] if values else 0, + "delta": delta, + } + ) + + activity_rows = recent_events(limit=20, box_type_filter=box_type_filter) + + return render_template( + "stats.html", + notice=notice, + days=days, + box_type_filter=box_type_filter, + box_types=BOX_TYPES, + total_quantity=total_quantity, + total_items=total_items, + low_stock_count=low_stock_count, + category_stats=category_stats, + max_category_quantity=max_category_quantity, + chart_mode=chart_mode, + chart_title=chart_title, + chart_rows=chart_rows, + max_chart_quantity=max_chart_quantity, + trend_points=trend_points, + trend_polyline=" ".join(svg_points), + min_value=min_value, + max_value=max_value, + period_net_change=period_net_change, + overall_total_quantity=overall_total_quantity, + inventory_share=inventory_share, + period_operation_count=period_operation_count, + active_days=active_days, + box_type_series=box_type_series, + activity_rows=activity_rows, + ) + + +@app.route("/stats/export") +def stats_export_csv(): + days = parse_days_value(request.args.get("days", "7")) + box_type_filter = parse_box_type_filter(request.args.get("box_type", "all")) + + boxes = Box.query.all() + box_by_id = {box.id: box for box in boxes} + components = Component.query.all() + + enabled_components = [ + c + for c in components + if c.is_enabled and ( + box_type_filter == "all" + or (box_by_id.get(c.box_id) and box_by_id[c.box_id].box_type == box_type_filter) + ) + ] + total_quantity = sum(c.quantity for c in enabled_components) + trend_points = build_trend_points_from_events(days, total_quantity, box_type_filter) + delta_by_day = query_event_daily_delta(days, box_type_filter) + + output = StringIO() + writer = csv.writer(output) + writer.writerow(["date", "inventory_total", "daily_delta", "days", "box_type_filter"]) + for point in trend_points: + writer.writerow( + [ + point["date"].isoformat(), + point["value"], + int(delta_by_day.get(point["date"], 0)), + days, + box_type_filter, + ] + ) + + csv_content = output.getvalue() + output.close() + filename = f"inventory_stats_{box_type_filter}_{days}d.csv" + + return Response( + csv_content, + mimetype="text/csv; charset=utf-8", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@app.route("/stats/clear", methods=["POST"]) +def clear_stats_logs(): + days = parse_days_value(request.form.get("days", "7")) + box_type_filter = parse_box_type_filter(request.form.get("box_type", "all")) + clear_scope = (request.form.get("scope", "current") or "current").strip() + + if clear_scope == "all": + deleted_count = InventoryEvent.query.delete() + db.session.commit() + notice = f"已清除全部统计日志,共 {deleted_count} 条" + return redirect(url_for("stats_page", days=days, box_type="all", notice=notice)) + + query = InventoryEvent.query + if box_type_filter != "all": + query = query.filter_by(box_type=box_type_filter) + deleted_count = query.delete(synchronize_session=False) + db.session.commit() + + if box_type_filter == "all": + notice = f"已清除当前筛选(全部分类)统计日志,共 {deleted_count} 条" + else: + label = BOX_TYPES.get(box_type_filter, {}).get("label", box_type_filter) + notice = f"已清除当前筛选({label})统计日志,共 {deleted_count} 条" + + 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() diff --git a/static/css/style.css b/static/css/style.css index 46cd4f4..c26dd10 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,12 +1,36 @@ :root { - --bg: #f3f7fb; + --bg: #f5f5f6; --card: #ffffff; - --text: #112031; - --muted: #506070; - --brand: #0c7a6f; - --brand-dark: #07564f; - --danger: #b42318; - --line: #d8e3ec; + --card-alt: #efeff1; + --text: #1f2023; + --muted: #6d7077; + --line: #d9dce2; + --accent: #e9854a; + --accent-press: #d9783f; + --danger: #bb5a3c; + --radius: 10px; + --space-1: 8px; + --space-2: 16px; + --space-3: 24px; + --space-4: 32px; + --shadow-card: 0 2px 10px rgba(0, 0, 0, 0.06); + --ring: 0 0 0 3px rgba(233, 133, 74, 0.2); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #141518; + --card: #1b1d21; + --card-alt: #24272d; + --text: #f3f4f6; + --muted: #a4a8b1; + --line: #323640; + --accent: #e9854a; + --accent-press: #d9783f; + --danger: #cc7256; + --shadow-card: 0 2px 12px rgba(0, 0, 0, 0.28); + --ring: 0 0 0 3px rgba(233, 133, 74, 0.25); + } } * { @@ -15,118 +39,443 @@ body { margin: 0; - font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; - background: radial-gradient(circle at top right, #defff8, var(--bg) 42%); + font-family: "SF Pro Text", "PingFang SC", "Source Han Sans SC", "MiSans", "Inter", sans-serif; color: var(--text); - scroll-behavior: smooth; + background: radial-gradient(circle at 100% 0, rgba(233, 133, 74, 0.08), transparent 32%), var(--bg); + line-height: 1.5; } .hero { display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 24px; - background: linear-gradient(125deg, #0c7a6f, #145e9e); - color: #fff; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--card) 88%, transparent); + backdrop-filter: blur(8px); + position: sticky; + top: 0; + z-index: 10; } .hero.slim { - padding: 18px 24px; + padding-top: var(--space-2); + padding-bottom: var(--space-2); } .hero h1 { margin: 0; - font-size: 24px; + font-size: 28px; + letter-spacing: 0.2px; +} + +.hero p { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.hero nav, +.hero .hero-actions { + display: flex; + gap: var(--space-1); + align-items: center; } .container { - max-width: 1080px; - margin: 18px auto; - padding: 0 16px 28px; + max-width: 1200px; + margin: 0 auto; + padding: var(--space-4); +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.metric-card { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: var(--space-2); + box-shadow: var(--shadow-card); +} + +.metric-title { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.metric-value { + margin: var(--space-1) 0 0; + font-size: 30px; + line-height: 1.15; +} + +.metrics-note { + margin: var(--space-2) 0 var(--space-4); + color: var(--muted); + font-size: 13px; +} + +.stats-layout { + display: grid; + gap: var(--space-2); + grid-template-columns: 2fr 1fr; + margin-bottom: var(--space-4); +} + +.stats-tabs { + display: flex; + align-items: center; + gap: var(--space-1); + margin-bottom: var(--space-2); + flex-wrap: wrap; +} + +.stats-filters { + margin-bottom: var(--space-2); +} + +.stats-filter-form { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--space-1); +} + +.stats-filter-form select { + min-width: 180px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--card); + color: var(--text); + min-height: 40px; + padding: 0 10px; + font: inherit; +} + +.mini-trend-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); +} + +.mini-trend-card { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: var(--space-2); + background: color-mix(in srgb, var(--card) 90%, var(--card-alt)); +} + +.mini-trend-svg { + width: 100%; + height: 56px; + display: block; +} + +.mini-trend-svg polyline { + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.trend-panel { + min-width: 0; +} + +.trend-chart { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: var(--space-2); + background: color-mix(in srgb, var(--card) 88%, var(--card-alt)); +} + +.trend-chart svg { + width: 100%; + height: 180px; + display: block; +} + +.trend-chart polyline { + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.trend-axis { + display: flex; + justify-content: space-between; + margin-top: var(--space-1); + color: var(--muted); + font-size: 12px; +} + +.chart-list { + display: grid; + gap: var(--space-2); +} + +.chart-row { + display: grid; + grid-template-columns: 130px 1fr 56px; + gap: var(--space-1); + align-items: center; +} + +.bar-track { + height: 8px; + border-radius: 99px; + background: var(--card-alt); + overflow: hidden; +} + +.bar-fill { + height: 100%; + background: var(--accent); + border-radius: inherit; +} + +.ring { + width: 164px; + height: 164px; + border-radius: 50%; + margin: var(--space-1) auto; + background: conic-gradient(var(--accent) 0 var(--ring-stop, 40%), var(--card-alt) var(--ring-stop, 40%) 100%); + position: relative; +} + +.ring::after { + content: ""; + position: absolute; + inset: 24px; + border-radius: 50%; + background: var(--card); + border: 1px solid var(--line); +} + +.ring-label { + text-align: center; + color: var(--muted); + font-size: 13px; } .layout-shell { display: grid; grid-template-columns: 230px minmax(0, 1fr); - gap: 14px; + gap: var(--space-2); align-items: start; } .catalog-sidebar { position: sticky; - top: 16px; + top: 88px; + display: grid; + gap: var(--space-2); + max-height: calc(100vh - 104px); + overflow-y: auto; + padding-right: 4px; } -.catalog-sidebar h2 { +.catalog-sidebar::-webkit-scrollbar { + width: 8px; +} + +.catalog-sidebar::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--line) 70%, var(--accent)); + border-radius: 999px; +} + +.icon-links { + margin-bottom: var(--space-1); +} + +.icon-link { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line); + border-radius: var(--radius); + color: var(--muted); + background: var(--card-alt); + text-decoration: none; + transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease; +} + +.icon-link:hover { + color: var(--accent-press); + border-color: var(--accent); + background: color-mix(in srgb, var(--card-alt) 70%, var(--accent) 30%); +} + +.sidebar-toggle { + width: 100%; +} + +.catalog-sidebar .side-low-stock { + max-height: 520px; + overflow: hidden; + opacity: 1; + transition: max-height 180ms ease, opacity 180ms ease, padding 180ms ease, border-width 180ms ease; +} + +.catalog-sidebar.compact .side-low-stock { + max-height: 0; + opacity: 0; + padding-top: 0; + padding-bottom: 0; + border-width: 0; +} + +.catalog-sidebar.manual-expand .side-low-stock { + max-height: 520px; + opacity: 1; + padding-top: var(--space-2); + padding-bottom: var(--space-2); + border-width: 1px; +} + +.side-metrics-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-1); +} + +.side-metrics .metric-card { + box-shadow: none; +} + +.side-metrics .metric-value { + font-size: 26px; +} + +.side-low-stock-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-1); +} + +.side-low-stock-list li { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 10px; + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-1); +} + +.side-low-stock-list p { + margin: 4px 0 0; +} + +.catalog-sidebar h2, +.catalog-content > h2 { margin-top: 0; - margin-bottom: 10px; + margin-bottom: var(--space-2); + font-size: 20px; } .catalog-nav { display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-1); } .catalog-nav a { display: block; text-decoration: none; color: var(--text); - background: #f5fafc; border: 1px solid var(--line); - border-radius: 10px; - padding: 8px 10px; + border-radius: var(--radius); + padding: 10px 12px; + background: var(--card); } .catalog-nav a:hover { - border-color: #9ccfd1; - background: #ecfbf8; + color: var(--accent-press); + border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); +} + +.catalog-nav a.active { + border-color: var(--accent); + color: var(--accent-press); + background: color-mix(in srgb, var(--card) 85%, var(--accent) 15%); +} + +.type-card { + display: flex; + flex-direction: column; + gap: var(--space-1); } .catalog-content { min-width: 0; } -.catalog-content > h2 { - margin-top: 0; -} - .btn { - display: inline-block; - border: 0; - background: var(--brand); + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + background: var(--accent); color: #fff; text-decoration: none; - border-radius: 10px; - padding: 9px 14px; + border-radius: var(--radius); + min-height: 40px; + padding: 0 14px; cursor: pointer; font-weight: 600; + transition: background-color 140ms ease, transform 140ms ease; } .btn:hover { - background: var(--brand-dark); + background: var(--accent-press); +} + +.btn:active { + transform: scale(0.98); } .btn-light { - background: #eaf2f9; - color: #183247; + background: var(--card-alt); + color: var(--text); + border-color: var(--line); +} + +.btn-light:hover { + background: color-mix(in srgb, var(--card-alt) 76%, var(--accent) 24%); } .btn-danger { background: var(--danger); } +.btn-danger:hover { + background: color-mix(in srgb, var(--danger) 85%, #000 15%); +} + .box-list { display: grid; - grid-template-columns: repeat(auto-fill, 300px); - gap: 14px; - justify-content: start; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: var(--space-2); } .group-panel { - margin-bottom: 16px; - scroll-margin-top: 20px; + margin-bottom: var(--space-3); + scroll-margin-top: 104px; } .group-title-row { @@ -134,33 +483,38 @@ body { flex-wrap: wrap; align-items: baseline; justify-content: space-between; - gap: 8px; - margin-bottom: 12px; + gap: var(--space-1); + margin-bottom: var(--space-2); } -.group-title-row h3 { +.group-title-row h3, +.box-card h4, +.box-card h3 { margin: 0; } -.group-desc { +.group-desc, +.hint, +.muted { color: var(--muted); font-size: 14px; } .new-box-form { display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 8px; - margin-bottom: 12px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--space-1); + margin-bottom: var(--space-2); } .new-box-form.compact { - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); } .new-box-form .suggest-preview { grid-column: 1 / -1; margin: 0; + min-height: 20px; } .new-box-form button { @@ -171,30 +525,23 @@ body { .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); + border-radius: var(--radius); + padding: var(--space-2); + box-shadow: var(--shadow-card); } .box-card { - width: 300px; - min-height: 260px; + min-height: 252px; overflow: hidden; } -.box-card h3 { - margin: 0 0 6px; -} - -.box-card h4 { - margin: 0 0 6px; -} - -.card-actions { +.card-actions, +.actions { display: flex; - gap: 8px; + flex-wrap: wrap; + gap: var(--space-1); align-items: center; - margin-bottom: 8px; + margin-top: var(--space-1); } .card-actions form { @@ -202,10 +549,10 @@ body { } .box-overview { - margin-top: 8px; + margin-top: var(--space-1); border: 1px dashed var(--line); - border-radius: 10px; - padding: 8px; + border-radius: var(--radius); + padding: 10px; } .box-overview summary { @@ -214,49 +561,132 @@ body { } .box-overview ul { - margin: 8px 0 0; + margin: var(--space-1) 0 0; padding-left: 18px; } .slot-grid { display: grid; - grid-template-columns: repeat(7, minmax(90px, 1fr)); - gap: 10px; + grid-template-columns: repeat(7, minmax(88px, 1fr)); + gap: var(--space-1); } .slot-grid-28-fixed { - grid-template-columns: repeat(7, minmax(90px, 1fr)); + grid-template-columns: repeat(7, minmax(88px, 1fr)); } .slot-grid-14-fixed { - grid-template-columns: repeat(2, minmax(160px, 1fr)); + grid-template-columns: repeat(2, minmax(168px, 1fr)); } .slot-grid-bag { - grid-template-columns: repeat(2, minmax(120px, 1fr)); + grid-template-columns: repeat(2, minmax(130px, 1fr)); } .slot { display: flex; flex-direction: column; gap: 4px; - min-height: 110px; + min-height: 112px; padding: 10px; - border-radius: 12px; + border-radius: var(--radius); text-decoration: none; border: 1px solid var(--line); color: var(--text); - background: #ffffff; + background: var(--card); + transition: border-color 140ms ease, color 140ms ease; + overflow: hidden; +} + +.slot small { + min-width: 0; + display: block; +} + +.slot-name { + font-size: 12px; + line-height: 1.3; + display: block; + overflow: hidden; + max-height: 2.8em; + word-break: break-all; + overflow-wrap: anywhere; +} + +.slot-name-text { + display: block; +} + +.slot:hover .slot-name { + overflow-y: auto; + scrollbar-width: thin; +} + +.slot-meta { + font-size: 12px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.slot-alert { + font-size: 12px; + color: var(--danger); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.quick-inbound-entry { + margin: 0; +} + +.quick-inbound-panel h2 { + margin-top: 0; + margin-bottom: var(--space-1); +} + +.quick-inbound-panel .quick-inbound-entry { + justify-content: flex-start; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-2); + z-index: 50; +} + +.modal-backdrop[hidden] { + display: none !important; +} + +.modal-card { + width: min(760px, 92vw); + max-height: 86vh; + overflow: auto; +} + +body.modal-open { + overflow: hidden; +} + +.slot:hover { + border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); } .slot.filled { - border-color: #9ccfd1; - background: #ecfbf8; + border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); + background: color-mix(in srgb, var(--card) 90%, var(--accent) 10%); } .slot.low-stock { - border-color: #fda4af; - background: #fff1f2; + border-color: color-mix(in srgb, var(--danger) 60%, var(--line)); } .slot-no { @@ -267,7 +697,7 @@ body { .form-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 10px; + gap: var(--space-2); } .form-grid label { @@ -281,38 +711,61 @@ body { grid-column: 1 / -1; } -input, +input[type="text"], +input[type="number"], +input[type="search"] { + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + background: transparent; + color: var(--text); + padding: 8px 2px; + font: inherit; + transition: border-color 140ms ease; +} + +input[type="text"]:focus, +input[type="number"]:focus, +input[type="search"]:focus, +textarea:focus { + outline: 0; + border-bottom-color: var(--accent); + box-shadow: var(--ring); +} + textarea { border: 1px solid var(--line); - border-radius: 10px; - padding: 9px 10px; + border-radius: var(--radius); + padding: 10px; + background: transparent; + color: var(--text); font: inherit; } -.actions { - display: flex; - gap: 8px; +input[type="checkbox"] { + accent-color: var(--accent); +} + +.alert, +.notice { + border-radius: var(--radius); + padding: 10px; } .alert { - background: #fff1f2; - border: 1px solid #fecdd3; - color: #9f1239; - border-radius: 10px; - padding: 10px; + background: color-mix(in srgb, var(--danger) 16%, var(--card)); + border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--line)); } .notice { - background: #ecfeff; - border: 1px solid #a5f3fc; - color: #155e75; - border-radius: 10px; - padding: 10px; + background: color-mix(in srgb, var(--accent) 16%, var(--card)); + border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--line)); } .search-row { display: flex; - gap: 8px; + gap: var(--space-1); + align-items: end; } .search-row input { @@ -332,25 +785,115 @@ th, td { text-align: left; border-bottom: 1px solid var(--line); - padding: 8px; + padding: 10px 8px; font-size: 14px; } -.hint { +th { color: var(--muted); - margin: 10px 0 0; + font-weight: 600; } .batch-input { width: 100%; - margin-top: 8px; - margin-bottom: 8px; + margin-top: var(--space-1); + margin-bottom: var(--space-1); } -@media (max-width: 760px) { - .hero { - flex-direction: column; - align-items: flex-start; +.entry-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: var(--space-2); + align-items: start; +} + +.entry-sidebar { + position: sticky; + top: 92px; +} + +.entry-main { + min-width: 0; +} + +.entry-guide h2 { + margin-top: 0; + margin-bottom: var(--space-1); +} + +.entry-sidebar .entry-guide { + max-height: calc(100vh - 120px); + overflow: auto; +} + +.guide-list { + margin: 0; + padding-left: 18px; + color: var(--text); +} + +.guide-list li { + margin: 4px 0; +} + +.guide-code { + margin: var(--space-1) 0 0; + padding: 8px; + border: 1px dashed var(--line); + border-radius: var(--radius); + background: color-mix(in srgb, var(--card) 86%, var(--card-alt)); + font: 11px/1.45 Consolas, "Cascadia Mono", monospace; + white-space: pre-wrap; +} + +.icon { + width: 24px; + height: 24px; + stroke: currentColor; + stroke-width: 2; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} + +.toast-stack { + position: fixed; + top: var(--space-2); + right: var(--space-2); + display: grid; + gap: var(--space-1); + z-index: 20; +} + +.toast { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + padding: 8px 12px; + min-width: 220px; + color: var(--text); + animation: toast-fade 1.6s ease; +} + +@keyframes toast-fade { + 0% { opacity: 0; transform: translateY(-6px); } + 12% { opacity: 1; transform: translateY(0); } + 88% { opacity: 1; transform: translateY(0); } + 100% { opacity: 0; transform: translateY(-4px); } +} + +@media (max-width: 980px) { + .metrics-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .stats-layout { + grid-template-columns: 1fr; + } + + .mini-trend-grid { + grid-template-columns: 1fr; } .layout-shell { @@ -359,6 +902,18 @@ td { .catalog-sidebar { position: static; + max-height: none; + overflow: visible; + padding-right: 0; + } + + .catalog-sidebar .side-low-stock, + .catalog-sidebar.compact .side-low-stock { + max-height: none; + opacity: 1; + padding-top: var(--space-2); + padding-bottom: var(--space-2); + border-width: 1px; } .catalog-nav { @@ -366,26 +921,51 @@ td { flex-wrap: wrap; } - .box-list { + .entry-shell { grid-template-columns: 1fr; } - .box-card { - width: 100%; - min-height: auto; + .entry-sidebar { + position: static; + } +} + +@media (max-width: 760px) { + .hero { + position: static; + flex-direction: column; + align-items: flex-start; + padding: var(--space-2); + } + + .hero h1 { + font-size: 24px; + } + + .container { + padding: var(--space-2); + } + + .metrics-grid { + grid-template-columns: 1fr; + } + + .box-list { + grid-template-columns: 1fr; } .slot-grid, .slot-grid-14-fixed, .slot-grid-bag { - grid-template-columns: repeat(4, minmax(70px, 1fr)); - } - - .new-box-form { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(110px, 1fr)); } + .new-box-form, .form-grid { grid-template-columns: 1fr; } + + .chart-row { + grid-template-columns: 100px 1fr 52px; + } } diff --git a/static/js/scanner.js b/static/js/scanner.js index 5fb68a2..60f9f4f 100644 --- a/static/js/scanner.js +++ b/static/js/scanner.js @@ -2,6 +2,24 @@ 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; } @@ -15,6 +33,13 @@ if (event.key === "Escape") { input.value = ""; input.focus(); + showToast("已清空搜索词"); + } + }); + + form.addEventListener("submit", function () { + if (input.value.trim()) { + showToast("正在搜索..."); } }); })(); diff --git a/templates/box.html b/templates/box.html index b4de7a7..0de4106 100644 --- a/templates/box.html +++ b/templates/box.html @@ -8,9 +8,14 @@
-

{{ box.name }} - {{ box_types.get(box.box_type, box_types['small_28']).label }}

-
@@ -23,10 +28,13 @@

{{ notice }}

{% endif %} +
+
+ {% if box.box_type == 'bag' %}

袋装记录

-

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

+

编号前缀: {{ box.slot_prefix }} | 一袋一种器件(同料号会自动合并)

@@ -37,7 +45,6 @@ - @@ -51,12 +58,11 @@ - {% else %} - + {% endfor %} @@ -66,14 +72,15 @@

新增单条

+

3步完成: 填写料号与名称 -> 填数量 -> 保存到袋装清单(同料号自动合并)

-
@@ -99,9 +102,10 @@

批量新增

-

每行一条, 格式: 料号, 名称, 数量, 规格, 位置备注, 备注。可用英文逗号或 Tab 分隔。

+

每行一条, 格式: 料号, 名称, 数量, 规格, 备注。可用英文逗号或 Tab 分隔;同料号自动合并。

- + +

建议格式: 名称尽量写品类+型号;规格只留关键参数。

@@ -112,24 +116,104 @@
{% for item in slots %} - {% if box.box_type in ['small_28', 'medium_14'] %} - 位置 {{ '%02d'|format(item.slot) }} - {% else %} {{ item.slot_code }} - {% endif %} {% if item.component %} - {{ item.component.name }} - 数量: {{ item.component.quantity }} + {{ item.component.name }} + 数量: {{ item.component.quantity }} {% if item.component.quantity < low_stock_threshold %} - 低库存预警 + 低库存预警 {% endif %} {% else %} - 空位 + 空位 {% endif %} {% endfor %}
+ + {% endif %} +
+ + +
+ diff --git a/templates/edit.html b/templates/edit.html index 2825b5a..2265553 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -8,8 +8,14 @@
-

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

- 返回宫格 +
+

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

+

步骤: 填写核心字段 -> 检查数量 -> 保存

+
+
@@ -17,48 +23,61 @@

{{ error }}

{% endif %} -
- - - - - - - +
+
+ + + + + + -
- - {% if component %} - {% if component.is_enabled %} - - {% else %} - - {% endif %} - - {% endif %} -
- +
+ + {% if component %} + {% if component.is_enabled %} + + {% else %} + + {% endif %} + + {% endif %} +
+ +
+ + +
diff --git a/templates/error.html b/templates/error.html index ed37c4e..1dd3c89 100644 --- a/templates/error.html +++ b/templates/error.html @@ -8,7 +8,10 @@
-

{{ status_code }} - {{ title }}

+
+

{{ status_code }} - {{ title }}

+

请检查输入参数后重试

+
回到首页
diff --git a/templates/index.html b/templates/index.html index fa9c683..e918ff6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -8,34 +8,106 @@
-

电子元件库存管理 v1.0

- 扫码/搜索 +
+

{% if separate_mode %}{{ box_types[current_box_type].label }}{% else %}库存管理{% endif %}

+

{% if separate_mode %}当前为独立分类界面,减少长列表翻找成本{% else %}极简中性灰布局,聚焦数量/分类/变动核心信息{% endif %}

+
+
-
- + {% endfor %} diff --git a/templates/stats.html b/templates/stats.html new file mode 100644 index 0000000..b030d0a --- /dev/null +++ b/templates/stats.html @@ -0,0 +1,181 @@ + + + + + + 库存统计 + + + +
+
+

库存统计

+

仅展示核心指标: 数量、分类、变动

+
+ +
+ +
+ {% if notice %} +

{{ notice }}

+ {% endif %} + +
+
+ + + + + 导出CSV + +
+
+ + + + + +
+ + + + + +
+
+ +
+
+

{% if box_type_filter == 'all' %}库存总量{% else %}分类库存量{% endif %}

+

{{ total_quantity }}

+ {% if box_type_filter != 'all' %} +

占总库存 {{ inventory_share }}%

+ {% endif %} +
+
+

周期操作次数

+

{{ period_operation_count }}

+
+
+

活跃天数

+

{{ active_days }}/{{ days }}

+
+
+

{{ days }}天净变动

+

{% if period_net_change > 0 %}+{% endif %}{{ period_net_change }}

+
+
+ +
+ 近7天 + 近30天 + 趋势基于库存变动日志实时计算,包含新增、快速入库、启停、删除的数量变化。 +
+ +
+
+

库存变动趋势

+ +

区间最小值 {{ min_value }} | 区间最大值 {{ max_value }}

+
+ +
+

{{ chart_title }}

+
+ {% for item in chart_rows %} + {% set width = 0 %} + {% if max_chart_quantity > 0 %} + {% set width = (item.quantity * 100 / max_chart_quantity)|round(0, 'floor') %} + {% endif %} +
+ {{ item.label }} + + {{ item.quantity }} +
+ {% endfor %} +
+
+
+ +
+

分类趋势快照

+
+ {% for row in box_type_series %} +
+

{{ row.label }}

+ + + +

当前 {{ row.latest }} | 净变动 {% if row.delta > 0 %}+{% endif %}{{ row.delta }}

+
+ {% endfor %} +
+
+ +
+

最近操作

+
+
数量 状态 规格位置备注 操作
{{ c.quantity }} {% if c.is_enabled %}启用{% else %}停用{% endif %} {{ c.specification or '-' }}{{ c.location or '-' }} 编辑
当前没有袋装记录,请先新增。当前没有袋装记录,请先新增。
{{ c.quantity }} {{ row.box_name }} / {{ row.slot_code }} {% if c.is_enabled %}启用{% else %}停用{% endif %}编辑编辑
+ + + + + + + + + + + {% for row in activity_rows %} + + + + + + + + {% else %} + + + + {% endfor %} + +
时间类型分类料号变动
{{ row.time }}{{ row.type }}{{ row.box_type }}{{ row.part_no }}{% if row.delta > 0 %}+{% endif %}{{ row.delta }}
暂无操作日志,先进行一次入库或编辑。
+
+
+ + + + + diff --git a/templates/types.html b/templates/types.html new file mode 100644 index 0000000..6fff779 --- /dev/null +++ b/templates/types.html @@ -0,0 +1,34 @@ + + + + + + 分类总览 + + + +
+
+

分类总览

+

将容器拆分为独立界面,避免长页面翻找

+
+ +
+ +
+
+ {% for item in type_cards %} +
+

{{ item.label }}

+

{{ item.count }}

+

{{ item.desc }}

+ 进入分类 +
+ {% endfor %} +
+
+ +