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:
17
README.md
17
README.md
@@ -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
438
app.py
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user