feat:集成 LCSC 产品 API 用于袋子管理

- 增加了 LCSC API 集成,可利用 app_id、access_key 和 secret_key 获取产品详情。
- 实现了用于安全 API 请求的一次性和签名生成。
- 通过新端点提升包容量管理,更新插槽容量。
- 更新界面,支持 LCSC 产品直接导入袋口。
- 改进了 API 响应和用户输入验证的错误处理。
- 重构箱子渲染逻辑,以适应新的包包功能和展示产品详情。
- 为与 LCSC 产品信息相关的新 UI 元素添加了 CSS 样式。
- 更新了 AI 设置页面,包含了 LCSC API 配置选项。
This commit is contained in:
2026-03-12 13:46:28 +08:00
parent f7a82528e7
commit 10da4c2859
8 changed files with 661 additions and 119 deletions

View File

@@ -105,6 +105,7 @@ $env:SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct"
- `袋装清单`:表格视图,支持单条新增和批量新增。
- `袋装清单` 仅使用编号前缀(如 `BAG`),不设置编号范围。
- `28格/14格` 支持快速入库:多行粘贴后自动分配空位。
- `28格/14格/自定义容器` 支持立创编号入库:进入对应格位编辑页后输入编号,自动拉取商品基础信息并写入当前格位。
- 支持按当前盒子导出打标 CSV仅导出启用记录可用于热敏打标机导入。
- 打标 CSV 列名为中英双语格式(如 `料号(part_no)``备注(note)`),便于直接识别。
@@ -140,8 +141,24 @@ $env:SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct"
### 3.7 AI 参数设置 `/ai/settings`
- 支持页面内编辑:`API URL / 模型名称 / API Key / 超时 / 低库存阈值 / 建议条目上限`
- 支持页面内编辑立创接口参数:`Base URL / Path / API Key / Header / Prefix / 请求编号字段 / 超时`
- 保存后立即生效,无需改代码。
### 3.8 立创编号入库 `/edit/<box_id>/<slot>/lcsc-import`
- 请求方式:`POST`
- 表单字段:
- `lcsc_product_id`:立创商品编号(默认按文档使用整数 `productId`
- `quantity`:写入数量
- 导入逻辑:
- `part_no` <- `productModel`(兜底 `productCode`
- `name` <- `productName`
- `specification` <- `brandName / encapStandard / catalogName`
- `note` <- `LCSC productCode + productId`
- 鉴权支持:
- `JOP签名`(推荐,示例中的 `app_id/access_key/secret_key`
- `简单Header API Key`(兼容模式)
## 4. 袋装批量新增格式
在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔:

438
app.py
View File

@@ -2,7 +2,14 @@ import os
import re
import csv
import json
import hmac
import base64
import random
import string
import hashlib
import time
import urllib.error
import urllib.parse
import urllib.request
from copy import deepcopy
from io import StringIO
@@ -25,6 +32,8 @@ db = SQLAlchemy(app)
LOW_STOCK_THRESHOLD = 5
BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json")
AI_SETTINGS_PATH = os.path.join(DB_DIR, "ai_settings.json")
LCSC_BASE_URL = "https://open-api.jlc.com"
LCSC_BASIC_PATH = "/lcsc/openapi/sku/product/basic"
AI_SETTINGS_DEFAULT = {
"api_url": os.environ.get(
"SILICONFLOW_API_URL",
@@ -38,6 +47,10 @@ AI_SETTINGS_DEFAULT = {
"timeout": int(os.environ.get("SILICONFLOW_TIMEOUT", "30") or "30"),
"restock_threshold": LOW_STOCK_THRESHOLD,
"restock_limit": 24,
"lcsc_timeout": int(os.environ.get("LCSC_TIMEOUT", "20") or "20"),
"lcsc_app_id": os.environ.get("LCSC_APP_ID", ""),
"lcsc_access_key": os.environ.get("LCSC_ACCESS_KEY", ""),
"lcsc_secret_key": os.environ.get("LCSC_SECRET_KEY", ""),
}
@@ -62,7 +75,7 @@ DEFAULT_BOX_TYPES = {
},
"bag": {
"label": "袋装清单",
"default_capacity": 1,
"default_capacity": 28,
"default_desc": "一袋一种器件,按清单管理",
"default_prefix": "BAG",
},
@@ -93,8 +106,8 @@ def _apply_box_type_overrides() -> None:
continue
BOX_TYPES[key][field] = value[field]
# Keep bag list capacity fixed by domain rule.
BOX_TYPES["bag"]["default_capacity"] = 1
# Keep bag container capacity fixed by domain rule.
BOX_TYPES["bag"]["default_capacity"] = 28
def _save_box_type_overrides() -> None:
@@ -159,12 +172,196 @@ def _get_ai_settings() -> dict:
except (TypeError, ValueError):
settings["restock_limit"] = 24
try:
settings["lcsc_timeout"] = max(5, int(settings.get("lcsc_timeout", 20)))
except (TypeError, ValueError):
settings["lcsc_timeout"] = 20
settings["api_url"] = (settings.get("api_url") or "").strip()
settings["model"] = (settings.get("model") or "").strip()
settings["api_key"] = (settings.get("api_key") or "").strip()
settings["lcsc_base_url"] = LCSC_BASE_URL
settings["lcsc_basic_path"] = LCSC_BASIC_PATH
settings["lcsc_app_id"] = (settings.get("lcsc_app_id") or "").strip()
settings["lcsc_access_key"] = (settings.get("lcsc_access_key") or "").strip()
settings["lcsc_secret_key"] = (settings.get("lcsc_secret_key") or "").strip()
return settings
def _generate_nonce(length: int = 32) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(random.choice(alphabet) for _ in range(length))
def _generate_jop_signature(method: str, path: str, timestamp: str, nonce: str, post_data: str, secret_key: str) -> str:
string_to_sign = f"{method}\n{path}\n{timestamp}\n{nonce}\n{post_data}\n"
digest = hmac.new(
secret_key.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha256,
).digest()
return base64.b64encode(digest).decode("utf-8")
def _extract_lcsc_product_id_from_input(raw_identifier: str) -> int | None:
text = (raw_identifier or "").strip()
if not text:
return None
# Accept full item detail URL and extract /23913.html.
try:
parsed = urllib.parse.urlparse(text)
except ValueError:
parsed = None
if parsed and parsed.netloc:
path = parsed.path or ""
m = re.search(r"/(\d+)\.html$", path)
if m:
return int(m.group(1))
return None
def _fetch_lcsc_product_basic(product_identifier: str, settings: dict) -> dict:
raw_identifier = (product_identifier or "").strip()
if not raw_identifier:
raise RuntimeError("立创商品链接不能为空")
product_id_from_input = _extract_lcsc_product_id_from_input(raw_identifier)
if product_id_from_input is None:
raise RuntimeError("请输入立创商品详情页链接,例如 https://item.szlcsc.com/23913.html")
app_id = settings.get("lcsc_app_id", "").strip()
access_key = settings.get("lcsc_access_key", "").strip()
secret_key = settings.get("lcsc_secret_key", "").strip()
if not app_id or not access_key or not secret_key:
raise RuntimeError("立创 JOP 鉴权参数不完整,请填写 app_id/access_key/secret_key")
timeout = int(settings.get("lcsc_timeout", 20))
def request_openapi(payload: dict) -> dict:
post_data_str = json.dumps(payload, ensure_ascii=False)
timestamp = str(int(time.time()))
nonce = _generate_nonce(32)
signature = _generate_jop_signature(
"POST",
LCSC_BASIC_PATH,
timestamp,
nonce,
post_data_str,
secret_key,
)
headers = {
"Content-Type": "application/json; utf-8",
"Authorization": (
f'JOP appid="{app_id}",accesskey="{access_key}",'
f'timestamp="{timestamp}",nonce="{nonce}",signature="{signature}"'
),
}
endpoint = LCSC_BASE_URL + LCSC_BASIC_PATH
req = urllib.request.Request(
endpoint,
data=post_data_str.encode("utf-8"),
method="POST",
headers=headers,
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"立创接口调用失败: HTTP {exc.code} {detail[:180]}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"立创接口调用失败: 连接失败 {exc.reason}") from exc
try:
response = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError("立创接口返回非 JSON 数据") from exc
code = response.get("code")
successful = response.get("successful", False)
if code != 200 and not successful:
message = str(response.get("message", "立创接口调用失败") or "立创接口调用失败")
raise RuntimeError(f"立创接口调用失败: code={code}, message={message}")
data = response.get("data") or {}
items = data.get("productBasicInfoVOList") or []
if not items:
raise RuntimeError("未查询到商品信息,请检查编号是否正确")
return items[0]
return request_openapi({"productId": product_id_from_input})
def _map_lcsc_product_to_component(product: dict) -> dict:
product_model = str(product.get("productModel") or "").strip()
product_code = str(product.get("productCode") or "").strip()
product_id = product.get("productId")
part_no = product_model or product_code or (str(product_id) if product_id is not None else "")
name = str(product.get("productName") or "").strip() or part_no or "未命名元件"
brand_name = str(product.get("brandName") or "").strip()
encap_standard = str(product.get("encapStandard") or "").strip()
catalog_name = str(product.get("catalogName") or "").strip()
# Prefer concise, searchable spec fields.
spec_parts = [brand_name, encap_standard, catalog_name]
specification = " / ".join([p for p in spec_parts if p])
arrange_map = {
"biandai": "编带",
"bianpai": "编排",
"daizhuang": "袋装",
"guanzhuang": "管装",
"hezhuang": "盒装",
"juan": "卷装",
"kun": "捆装",
"tuopan": "托盘",
"xiangzhuang": "箱装",
}
unit_map = {
"pan": "圆盘",
"bao": "",
"ben": "",
"dai": "",
"guan": "",
"he": "",
"juan": "",
"kun": "",
"mi": "",
"tuopan": "托盘",
"xiang": "",
}
product_arrange_raw = str(product.get("productArrange") or "").strip().lower()
product_arrange = arrange_map.get(product_arrange_raw, product_arrange_raw)
min_packet_number = product.get("minPacketNumber")
min_packet_unit_raw = str(product.get("minPacketUnit") or "").strip().lower()
min_packet_unit = unit_map.get(min_packet_unit_raw, min_packet_unit_raw)
note_bits = []
if product_code:
note_bits.append(f"LCSC {product_code}")
if product_id is not None:
note_bits.append(f"ID {product_id}")
if product_arrange:
note_bits.append(f"编排 {product_arrange}")
if min_packet_number:
unit_suffix = min_packet_unit or ""
note_bits.append(f"最小包装 {min_packet_number}{unit_suffix}")
note = " | ".join(note_bits)
return {
"part_no": part_no,
"name": name,
"specification": specification,
"note": note,
}
class Box(db.Model):
__tablename__ = "boxes"
@@ -256,8 +453,6 @@ 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}"
@@ -290,16 +485,69 @@ def infer_base_name(box: Box) -> str:
return base or box.name
def _extract_lcsc_code_from_text(text: str) -> str:
raw = (text or "").upper()
match = re.search(r"\bC\d{3,}\b", raw)
return match.group(0) if match else ""
def _parse_slot_spec_fields(specification: str) -> dict:
parts = [p.strip() for p in (specification or "").split("/") if p.strip()]
return {
"brand": parts[0] if len(parts) >= 1 else "",
"package": parts[1] if len(parts) >= 2 else "",
"usage": parts[2] if len(parts) >= 3 else "",
}
def _parse_note_detail_fields(note: str) -> dict:
raw = (note or "")
lcsc_code = _extract_lcsc_code_from_text(raw)
product_id = ""
id_match = re.search(r"\b(?:ID|productId)\s*(\d+)\b", raw, flags=re.IGNORECASE)
if id_match:
product_id = id_match.group(1)
arrange = ""
arrange_match = re.search(r"编排\s*([^|]+)", raw)
if arrange_match:
arrange = arrange_match.group(1).strip()
min_pack = ""
min_pack_match = re.search(r"最小包装\s*([^|]+)", raw)
if min_pack_match:
min_pack = min_pack_match.group(1).strip()
return {
"lcsc_code": lcsc_code,
"product_id": product_id,
"arrange": arrange,
"min_pack": min_pack,
}
def slot_data_for_box(box: Box):
components = Component.query.filter_by(box_id=box.id).all()
slot_map = {c.slot_index: c for c in components}
slots = []
for slot in range(1, box.slot_capacity + 1):
component = slot_map.get(slot)
lcsc_code = _extract_lcsc_code_from_text(component.note if component else "")
if not lcsc_code and component:
lcsc_code = _extract_lcsc_code_from_text(component.part_no)
spec_fields = _parse_slot_spec_fields(component.specification) if component else {
"brand": "",
"package": "",
"usage": "",
}
slots.append(
{
"slot": slot,
"slot_code": slot_code_for_box(box, slot),
"component": slot_map.get(slot),
"component": component,
"lcsc_code": lcsc_code,
"spec_fields": spec_fields,
}
)
return slots
@@ -356,16 +604,14 @@ def normalize_legacy_data() -> None:
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}"
box.name = "袋装清单"
# 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']}",
name="袋装清单",
description=default_meta["default_desc"],
box_type="bag",
slot_capacity=default_meta["default_capacity"],
@@ -377,6 +623,53 @@ def normalize_legacy_data() -> None:
db.session.commit()
def get_fixed_bag_box() -> Box:
bag_box = Box.query.filter_by(box_type="bag").order_by(Box.id.asc()).first()
if bag_box:
return bag_box
meta = BOX_TYPES["bag"]
bag_box = Box(
name="袋装清单",
description=meta["default_desc"],
box_type="bag",
slot_capacity=meta["default_capacity"],
slot_prefix=meta["default_prefix"],
start_number=1,
)
db.session.add(bag_box)
db.session.commit()
return bag_box
@app.route("/box/<int:box_id>/bag-capacity", methods=["POST"])
def update_bag_capacity(box_id: int):
box = Box.query.get_or_404(box_id)
if box.box_type != "bag":
return bad_request("当前容器不是袋装清单", box.box_type)
try:
slot_capacity = _parse_non_negative_int(request.form.get("slot_capacity", ""), box.slot_capacity)
except ValueError:
return render_box_page(box, error="袋位数量必须是大于等于 1 的整数")
if slot_capacity < 1:
return render_box_page(box, error="袋位数量必须是大于等于 1 的整数")
max_used_slot = (
db.session.query(db.func.max(Component.slot_index))
.filter_by(box_id=box.id)
.scalar()
or 0
)
if max_used_slot > slot_capacity:
return render_box_page(box, error=f"袋位数量不能小于已使用位置 {max_used_slot}")
box.slot_capacity = slot_capacity
db.session.commit()
return redirect(url_for("view_box", box_id=box.id, notice="袋位数量已更新"))
def make_overview_rows(box: Box):
enabled_components = (
Component.query.filter_by(box_id=box.id, is_enabled=True)
@@ -473,12 +766,10 @@ def bad_request(message: str, box_type: str = ""):
def render_box_page(box: Box, error: str = "", notice: str = ""):
slots = slot_data_for_box(box)
bag_rows = bag_rows_for_box(box) if box.box_type == "bag" else []
return render_template(
"box.html",
box=box,
slots=slots,
bag_rows=bag_rows,
box_types=BOX_TYPES,
slot_range=slot_range_label(box),
low_stock_threshold=LOW_STOCK_THRESHOLD,
@@ -488,9 +779,6 @@ def render_box_page(box: Box, error: str = "", notice: str = ""):
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
@@ -946,6 +1234,9 @@ def type_page(box_type: str):
if box_type not in BOX_TYPES:
return bad_request("无效盒子类型", "")
if box_type == "bag":
return render_box_page(get_fixed_bag_box())
dashboard = build_dashboard_context()
return render_template(
@@ -970,8 +1261,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 box_type == "bag":
return redirect(url_for("type_page", box_type="bag"))
if not base_name:
return bad_request("盒子名称不能为空", box_type)
@@ -1109,7 +1400,7 @@ def update_box(box_id: int):
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)
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)
@@ -1204,6 +1495,13 @@ def export_box_labels_csv(box_id: int):
"位置编号(slot_code)",
"料号(part_no)",
"名称(name)",
"品牌(brand)",
"封装(package)",
"用途(usage)",
"立创编号(lcsc_code)",
"立创商品ID(lcsc_product_id)",
"商品编排(arrange)",
"最小包装(min_pack)",
"规格(specification)",
"数量(quantity)",
"位置备注(location)",
@@ -1213,12 +1511,23 @@ def export_box_labels_csv(box_id: int):
for c in rows:
slot_code = slot_code_for_box(box, c.slot_index)
spec_fields = _parse_slot_spec_fields(c.specification)
note_fields = _parse_note_detail_fields(c.note)
if not note_fields["lcsc_code"]:
note_fields["lcsc_code"] = _extract_lcsc_code_from_text(c.part_no)
writer.writerow(
[
box.name,
slot_code,
c.part_no or "",
c.name or "",
spec_fields["brand"],
spec_fields["package"],
spec_fields["usage"],
note_fields["lcsc_code"],
note_fields["product_id"],
note_fields["arrange"],
note_fields["min_pack"],
c.specification or "",
int(c.quantity or 0),
c.location or "",
@@ -1325,9 +1634,6 @@ def quick_inbound(box_id: int):
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()
@@ -1534,6 +1840,8 @@ def edit_component(box_id: int, slot: int):
return "无效的格子编号", 400
search_query = request.args.get("q", "").strip()
notice = request.args.get("notice", "").strip()
error = request.args.get("error", "").strip()
component = Component.query.filter_by(box_id=box.id, slot_index=slot).first()
if request.method == "POST":
@@ -1599,6 +1907,7 @@ def edit_component(box_id: int, slot: int):
slot_code=slot_code_for_box(box, slot),
component=component,
error=error,
notice=notice,
search_query=search_query_post,
)
@@ -1613,6 +1922,7 @@ def edit_component(box_id: int, slot: int):
slot_code=slot_code_for_box(box, slot),
component=component,
error=error,
notice=notice,
search_query=search_query_post,
)
@@ -1654,10 +1964,70 @@ def edit_component(box_id: int, slot: int):
slot=slot,
slot_code=slot_code_for_box(box, slot),
component=component,
error=error,
notice=notice,
search_query=search_query,
)
@app.route("/edit/<int:box_id>/<int:slot>/lcsc-import", methods=["POST"])
def lcsc_import_to_edit_slot(box_id: int, slot: int):
box = Box.query.get_or_404(box_id)
if slot < 1 or slot > box.slot_capacity:
return redirect(url_for("edit_component", box_id=box.id, slot=slot, error="目标位置超出当前容器范围"))
try:
quantity = _parse_non_negative_int(request.form.get("quantity", "0"), 0)
except ValueError:
return redirect(url_for("edit_component", box_id=box.id, slot=slot, error="数量必须是大于等于 0 的整数"))
settings = _get_ai_settings()
product_identifier = request.form.get("lcsc_product_id", "").strip()
try:
product = _fetch_lcsc_product_basic(product_identifier, settings)
except RuntimeError as exc:
return redirect(url_for("edit_component", box_id=box.id, slot=slot, error=f"立创导入失败: {exc}"))
mapped = _map_lcsc_product_to_component(product)
if not mapped["part_no"] or not mapped["name"]:
return redirect(url_for("edit_component", box_id=box.id, slot=slot, error="立创导入失败: 商品信息缺少料号或名称"))
component = Component.query.filter_by(box_id=box.id, slot_index=slot).first()
old_enabled_qty = int(component.quantity or 0) if component and component.is_enabled else 0
if component is None:
component = Component(box_id=box.id, slot_index=slot)
db.session.add(component)
component.part_no = mapped["part_no"]
component.name = mapped["name"]
component.specification = mapped["specification"] or None
component.note = mapped["note"] or None
component.quantity = quantity
component.is_enabled = True
delta = int(component.quantity or 0) - old_enabled_qty
if delta:
log_inventory_event(
event_type="component_save",
delta=delta,
box=box,
component=component,
part_no=component.part_no,
)
db.session.commit()
slot_code = slot_code_for_box(box, slot)
return redirect(
url_for(
"edit_component",
box_id=box.id,
slot=slot,
notice=f"立创导入成功: {mapped['part_no']} 已写入 {slot_code}",
)
)
@app.route("/search")
def search_page():
keyword = request.args.get("q", "").strip()
@@ -1852,6 +2222,9 @@ def ai_settings_page():
api_url = request.form.get("api_url", "").strip()
model = request.form.get("model", "").strip()
api_key = request.form.get("api_key", "").strip()
lcsc_app_id = request.form.get("lcsc_app_id", "").strip()
lcsc_access_key = request.form.get("lcsc_access_key", "").strip()
lcsc_secret_key = request.form.get("lcsc_secret_key", "").strip()
try:
timeout = int((request.form.get("timeout", "30") or "30").strip())
@@ -1879,10 +2252,21 @@ def ai_settings_page():
error = "补货条目数必须是大于等于 1 的整数"
restock_limit = settings["restock_limit"]
try:
lcsc_timeout = int((request.form.get("lcsc_timeout", "20") or "20").strip())
if lcsc_timeout < 5:
raise ValueError
except ValueError:
if not error:
error = "立创接口超时时间必须是大于等于 5 的整数"
lcsc_timeout = settings["lcsc_timeout"]
if not api_url and not error:
error = "API URL 不能为空"
if not model and not error:
error = "模型名称不能为空"
if (not lcsc_app_id or not lcsc_access_key or not lcsc_secret_key) and not error:
error = "立创接口需要填写 app_id、access_key、secret_key"
if not error:
settings = {
@@ -1892,6 +2276,12 @@ def ai_settings_page():
"timeout": timeout,
"restock_threshold": restock_threshold,
"restock_limit": restock_limit,
"lcsc_base_url": LCSC_BASE_URL,
"lcsc_basic_path": LCSC_BASIC_PATH,
"lcsc_timeout": lcsc_timeout,
"lcsc_app_id": lcsc_app_id,
"lcsc_access_key": lcsc_access_key,
"lcsc_secret_key": lcsc_secret_key,
}
_save_ai_settings(settings)
return redirect(url_for("ai_settings_page", notice="AI参数已保存"))
@@ -1904,6 +2294,12 @@ def ai_settings_page():
"timeout": timeout,
"restock_threshold": restock_threshold,
"restock_limit": restock_limit,
"lcsc_base_url": LCSC_BASE_URL,
"lcsc_basic_path": LCSC_BASIC_PATH,
"lcsc_timeout": lcsc_timeout,
"lcsc_app_id": lcsc_app_id,
"lcsc_access_key": lcsc_access_key,
"lcsc_secret_key": lcsc_secret_key,
}
)

View File

@@ -4,5 +4,16 @@
"api_key": "sk-pekgnbdvwgydxzteabnykswjadkitoopwcekmksydfoslmlo",
"timeout": 30,
"restock_threshold": 2,
"restock_limit": 24
"restock_limit": 24,
"lcsc_auth_mode": "jop",
"lcsc_base_url": "https://open-api.jlc.com",
"lcsc_basic_path": "/lcsc/openapi/sku/product/basic",
"lcsc_api_key": "",
"lcsc_api_key_header": "Authorization",
"lcsc_api_key_prefix": "Bearer ",
"lcsc_request_id_field": "productId",
"lcsc_timeout": 20,
"lcsc_app_id": "553906741933318145",
"lcsc_access_key": "2c1f0cd581e14151a9dbf82a4a4da961",
"lcsc_secret_key": "g3c4GEv5EA3KBNHcnwh3TCodokzN7C1E"
}

View File

@@ -660,11 +660,26 @@ body {
grid-template-columns: repeat(2, minmax(130px, 1fr));
}
.slot-grid-bag-fixed {
grid-template-columns: repeat(7, minmax(88px, 1fr));
}
.slot-grid-bag-fixed .slot {
min-height: 136px;
height: 136px;
}
.slot-toolbar {
display: flex;
justify-content: flex-end;
margin: 0 0 var(--space-1);
}
.slot {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 112px;
min-height: 136px;
padding: 10px;
border-radius: var(--radius);
text-decoration: none;
@@ -690,6 +705,16 @@ body {
overflow-wrap: anywhere;
}
.slot-part {
font-size: 12px;
font-weight: 700;
line-height: 1.25;
letter-spacing: 0.1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-name-text {
display: block;
}
@@ -707,6 +732,41 @@ body {
text-overflow: ellipsis;
}
.slot-details {
display: grid;
gap: 2px;
}
.slot-field {
font-size: 12px;
color: color-mix(in srgb, var(--text) 86%, var(--muted));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-lcsc {
width: fit-content;
max-width: 100%;
font-size: 11px;
line-height: 1.2;
padding: 3px 6px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 55%, var(--line));
background: color-mix(in srgb, var(--accent) 16%, var(--card));
color: color-mix(in srgb, var(--accent-press) 75%, var(--text));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: copy;
user-select: all;
}
.slot-lcsc.copied {
border-color: var(--accent-press);
background: color-mix(in srgb, var(--accent) 26%, var(--card));
}
.slot-alert {
font-size: 12px;
color: var(--danger);
@@ -771,6 +831,27 @@ body.modal-open {
color: var(--muted);
}
.slot-grid.compact .slot {
min-height: 96px;
}
.slot-grid-bag-fixed.compact .slot {
min-height: 96px;
height: 96px;
}
.slot-grid.compact .slot-name {
max-height: 1.35em;
}
.slot-grid.compact .slot-spec,
.slot-grid.compact .slot-lcsc,
.slot-grid.compact .slot-field,
.slot-grid.compact .slot-details,
.slot-grid.compact .slot-alert {
display: none;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;

View File

@@ -27,6 +27,7 @@
<section class="panel">
<form class="form-grid" method="post">
<h2 class="full">AI补货建议参数</h2>
<label>
API URL *
<input type="text" name="api_url" required value="{{ settings.api_url }}" placeholder="https://api.siliconflow.cn/v1/chat/completions">
@@ -51,6 +52,27 @@
建议条目上限
<input type="number" name="restock_limit" min="1" value="{{ settings.restock_limit }}">
</label>
<h2 class="full">立创商品信息接口参数</h2>
<p class="hint full">固定 Base URL: <code>https://open-api.jlc.com</code></p>
<p class="hint full">固定请求路径: <code>/lcsc/openapi/sku/product/basic</code></p>
<label class="full">
app_id
<input type="text" name="lcsc_app_id" value="{{ settings.lcsc_app_id }}" placeholder="JOP appid">
</label>
<label>
access_key
<input type="text" name="lcsc_access_key" value="{{ settings.lcsc_access_key }}" placeholder="JOP accesskey">
</label>
<label>
secret_key
<input type="text" name="lcsc_secret_key" value="{{ settings.lcsc_secret_key }}" placeholder="JOP secretkey">
</label>
<label>
立创接口超时(秒)
<input type="number" name="lcsc_timeout" min="5" value="{{ settings.lcsc_timeout }}">
</label>
<p class="hint full">输入 C 编号(如 <code>C23186</code>)时,系统按 <code>productCode</code> 查询;输入纯数字时按 <code>productId</code> 查询。</p>
<div class="actions full">
<button class="btn" type="submit">保存参数</button>
<a class="btn btn-light" href="{{ url_for('types_page') }}">取消</a>

View File

@@ -14,7 +14,11 @@
</div>
<nav class="hero-actions">
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
{% if box.box_type == 'bag' %}
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
{% else %}
<a class="btn btn-light" href="{{ url_for('type_page', box_type=box.box_type) }}">返回上一级容器</a>
{% endif %}
<a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
<a class="btn btn-light" href="{{ url_for('export_box_labels_csv', box_id=box.id) }}">导出打标CSV</a>
@@ -31,96 +35,31 @@
<div class="entry-shell">
<section class="entry-main">
{% if box.box_type == 'bag' %}
<section class="panel">
<h2>袋装记录</h2>
<p class="group-desc">编号前缀: {{ box.slot_prefix }} | 一袋一种器件(同料号会自动合并)</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>袋位编号</th>
<th>料号</th>
<th>名称</th>
<th>数量</th>
<th>状态</th>
<th>规格</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for row in bag_rows %}
{% set c = row.component %}
<tr>
<td>{{ row.slot_code }}</td>
<td>{{ c.part_no }}</td>
<td>{{ c.name }}</td>
<td>{{ c.quantity }}</td>
<td>{% if c.is_enabled %}启用{% else %}停用{% endif %}</td>
<td>{{ c.specification or '-' }}</td>
<td><a href="{{ url_for('edit_component', box_id=box.id, slot=c.slot_index) }}">编辑</a></td>
</tr>
{% else %}
<tr>
<td colspan="6">当前没有袋装记录,请先新增。</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="panel">
<h2>新增单条</h2>
<p class="hint">3步完成: 填写料号与名称 -> 填数量 -> 保存到袋装清单(同料号自动合并)</p>
<form class="form-grid" method="post" action="{{ url_for('add_bag_item', box_id=box.id) }}">
<label>
料号 *
<input type="text" name="part_no" required placeholder="如 STM32F103C8T6">
</label>
<label>
名称 *
<input type="text" name="name" required placeholder="如 MCU STM32F103C8T6">
</label>
<label>
数量
<input type="number" min="0" name="quantity" value="0">
</label>
<label>
规格
<input type="text" name="specification" placeholder="如 Cortex-M3 / LQFP-48">
</label>
<label class="full">
备注
<input type="text" name="note" placeholder="如 LCSC item 9243">
</label>
<div class="actions full">
<button class="btn" type="submit">新增记录</button>
</div>
</form>
</section>
<section class="panel">
<h2>批量新增</h2>
<p class="hint">每行一条, 格式: 料号, 名称, 数量, 规格, 备注。可用英文逗号或 Tab 分隔;同料号自动合并。</p>
<form method="post" action="{{ url_for('add_bag_items_batch', box_id=box.id) }}">
<textarea class="batch-input" name="lines" rows="8" placeholder="10K-0603, 贴片电阻10K, 500, 0603, 常用\n100nF-0603, 电容100nF, 300, 0603, X7R"></textarea>
<p class="hint">建议格式: 名称尽量写品类+型号;规格只留关键参数。</p>
<div class="actions">
<button class="btn" type="submit">批量导入</button>
</div>
</form>
</section>
{% else %}
<p class="group-desc">容量: {{ box.slot_capacity }} 位 | 编号范围: {{ slot_range }}</p>
<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 %}">
<div class="slot-toolbar">
<button class="btn btn-light" type="button" id="slot-density-toggle" aria-pressed="false">切换到精简模式</button>
</div>
<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.box_type == 'bag' %} slot-grid-bag-fixed{% elif box.slot_capacity <= 4 %} slot-grid-bag{% endif %}">
{% for item in slots %}
<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) }}">
<span class="slot-no">{{ item.slot_code }}</span>
{% if item.component %}
<small class="slot-part" title="{{ item.component.part_no }}">{{ item.component.part_no }}</small>
<small class="slot-name" title="{{ item.component.name }}"><span class="slot-name-text">{{ item.component.name }}</span></small>
<div class="slot-details">
{% if item.spec_fields.package %}
<small class="slot-field" title="封装">封装: {{ item.spec_fields.package }}</small>
{% endif %}
{% if item.spec_fields.usage %}
<small class="slot-field" title="用途/分类">用途: {{ item.spec_fields.usage }}</small>
{% endif %}
</div>
<small class="slot-meta">数量: {{ item.component.quantity }}</small>
<div class="slot-details">
{% if item.lcsc_code %}
<small class="slot-lcsc" title="点击复制立创编号" data-copy="{{ item.lcsc_code }}">编号: {{ item.lcsc_code }}</small>
{% endif %}
</div>
{% if item.component.quantity < low_stock_threshold %}
<small class="slot-alert">低库存预警</small>
{% endif %}
@@ -147,11 +86,25 @@
</form>
</div>
</div>
{% endif %}
</section>
<aside class="entry-sidebar">
{% if box.box_type != 'bag' %}
{% if box.box_type == 'bag' %}
<section class="panel quick-inbound-panel">
<h2>袋位设置</h2>
<p class="hint">袋装清单是固定大容器,但袋位数量可以按实际需要调整。</p>
<form class="form-grid" method="post" action="{{ url_for('update_bag_capacity', box_id=box.id) }}">
<label>
袋位数量
<input type="number" name="slot_capacity" min="1" value="{{ box.slot_capacity }}">
</label>
<div class="actions full">
<button class="btn" type="submit">更新袋位数量</button>
</div>
</form>
</section>
{% endif %}
<section class="panel quick-inbound-panel">
<h2>快速入库</h2>
<div class="card-actions quick-inbound-entry">
@@ -159,7 +112,6 @@
</div>
<p class="hint">弹窗录入,不占主页面空间。</p>
</section>
{% endif %}
<section class="panel entry-guide">
<h2>轻量入库规范</h2>
@@ -179,6 +131,32 @@
</div>
</main>
<script>
(function () {
var grid = document.querySelector('.slot-grid');
var toggleBtn = document.getElementById('slot-density-toggle');
if (!grid || !toggleBtn) {
return;
}
var storageKey = 'slot-density-mode';
var mode = window.localStorage.getItem(storageKey) || 'detailed';
function applyMode(nextMode) {
var compact = nextMode === 'compact';
grid.classList.toggle('compact', compact);
toggleBtn.textContent = compact ? '切换到详细模式' : '切换到精简模式';
toggleBtn.setAttribute('aria-pressed', compact ? 'true' : 'false');
}
applyMode(mode);
toggleBtn.addEventListener('click', function () {
mode = mode === 'compact' ? 'detailed' : 'compact';
window.localStorage.setItem(storageKey, mode);
applyMode(mode);
});
})();
(function () {
var openBtn = document.getElementById('open-quick-inbound');
var closeBtn = document.getElementById('close-quick-inbound');
@@ -215,6 +193,30 @@
}
});
})();
(function () {
var codeNodes = document.querySelectorAll('.slot-lcsc[data-copy]');
if (!codeNodes.length) {
return;
}
codeNodes.forEach(function (node) {
node.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
var text = (node.getAttribute('data-copy') || '').trim();
if (!text || !navigator.clipboard || !navigator.clipboard.writeText) {
return;
}
navigator.clipboard.writeText(text).then(function () {
node.classList.add('copied');
window.setTimeout(function () {
node.classList.remove('copied');
}, 900);
});
});
});
})();
</script>
</body>
</html>

View File

@@ -23,6 +23,9 @@
{% if error %}
<p class="alert">{{ error }}</p>
{% endif %}
{% if notice %}
<p class="notice">{{ notice }}</p>
{% endif %}
<div class="entry-shell">
<section class="entry-main">
@@ -64,6 +67,25 @@
</section>
<aside class="entry-sidebar">
<section class="panel quick-inbound-panel">
<h2>立创编号入库</h2>
<p class="hint">当前编辑位置: {{ slot_code }}。仅支持粘贴立创商品详情页链接,系统会自动提取 itemId 并查询。</p>
<form class="form-grid" method="post" action="{{ url_for('lcsc_import_to_edit_slot', box_id=box.id, slot=slot) }}">
<label>
立创商品详情页链接
<input type="text" name="lcsc_product_id" required placeholder="如 https://item.szlcsc.com/23913.html">
</label>
<label>
数量
<input type="number" name="quantity" min="0" value="0">
</label>
<div class="actions full">
<button class="btn" type="submit">拉取并写入当前位</button>
<a class="btn btn-light" href="{{ url_for('ai_settings_page') }}">接口参数</a>
</div>
</form>
</section>
<section class="panel entry-guide">
<h2>轻量入库规范</h2>
<p class="hint">先保证可检索,再补充关键参数,不追求一次填很全。</p>

View File

@@ -60,7 +60,6 @@
<span class="group-desc">{{ meta.default_desc }}</span>
</div>
{% if key != 'bag' %}
<form class="new-box-form" method="post" action="{{ url_for('create_box') }}" {% if loop.first %}id="quick-add"{% endif %}>
<input type="hidden" name="box_type" value="{{ key }}">
{% if separate_mode %}<input type="hidden" name="return_to_type" value="{{ current_box_type }}">{% endif %}
@@ -75,19 +74,13 @@
<button class="btn" type="submit">新增盒子</button>
<span class="hint suggest-preview"></span>
</form>
{% else %}
<p class="hint">袋装清单为固定容器(大盒),不需要新增盒子。</p>
{% endif %}
<section class="box-list">
{% for item in groups[key] %}
<article class="box-card">
<h4>{{ item.box.name }}</h4>
<p>{{ item.box.description or '暂无描述' }}</p>
{% if item.box.box_type == 'bag' %}
<p>编号前缀: {{ item.box.slot_prefix }} | 袋装清单不使用范围</p>
<p>已记录: {{ item.used_count }} 项</p>
{% elif item.box.box_type == 'custom' %}
{% if item.box.box_type == 'custom' %}
<p>格数: {{ item.box.slot_capacity }} | 编号前缀: {{ item.box.slot_prefix }} | 范围: {{ item.slot_range }}</p>
<p>已启用: {{ item.used_count }}/{{ item.box.slot_capacity }}</p>
{% else %}
@@ -97,12 +90,10 @@
<div class="card-actions">
<a class="btn" href="{{ url_for('view_box', box_id=item.box.id) }}">进入列表</a>
{% if item.box.box_type != 'bag' %}
<form method="post" action="{{ url_for('delete_box', box_id=item.box.id) }}" onsubmit="return confirm('确认删除这个盒子及其内部记录吗?')">
{% if separate_mode %}<input type="hidden" name="return_to_type" value="{{ current_box_type }}">{% endif %}
<button class="btn btn-danger" type="submit">删除</button>
</form>
{% endif %}
</div>
<details class="box-overview">