Files
broccoliandClaude Fable 5 edfb216561 激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、
PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。

图片相关:
- 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张
- 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG
- 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷
- 详情页缩略图宫格与全屏 lightbox,公开看板同样可见

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:06:02 +08:00

156 lines
6.3 KiB
Python

from __future__ import annotations
import json
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Any
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .models import CustomFieldDefinition, CustomFieldValue, MaterialRecord
def options_for(definition: CustomFieldDefinition) -> list[str]:
try:
return json.loads(definition.options_json) if definition.options_json else []
except (json.JSONDecodeError, TypeError):
return []
def definition_json(definition: CustomFieldDefinition, usage_count: int | None = None) -> dict[str, Any]:
data = {
"id": definition.id,
"field_key": definition.field_key,
"label": definition.label,
"input_type": definition.input_type,
"is_required": definition.is_required,
"is_active": definition.is_active,
"is_public": definition.is_public,
"unit": definition.unit,
"options": options_for(definition),
"sort_order": definition.sort_order,
"created_at": definition.created_at.isoformat(),
"updated_at": definition.updated_at.isoformat(),
}
if usage_count is not None:
data["usage_count"] = usage_count
return data
def active_definitions(db: Session) -> list[CustomFieldDefinition]:
return list(db.scalars(select(CustomFieldDefinition).where(CustomFieldDefinition.is_active.is_(True)).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.id)).all())
def _empty(value: Any) -> bool:
return value is None or (isinstance(value, str) and not value.strip())
def validate_raw_value(definition: CustomFieldDefinition, value: Any) -> str | None:
if _empty(value):
if definition.is_required:
raise HTTPException(422, f"{definition.label}:该字段为必填项")
return None
raw = str(value).strip()
try:
if definition.input_type == "number":
if not Decimal(raw).is_finite():
raise ValueError
elif definition.input_type == "integer":
number = Decimal(raw)
if isinstance(value, bool) or not number.is_finite() or number != number.to_integral_value():
raise ValueError
elif definition.input_type == "date":
date.fromisoformat(raw)
elif definition.input_type == "boolean":
if raw.lower() not in {"true", "false", "1", "0"}:
raise ValueError
raw = "true" if raw.lower() in {"true", "1"} else "false"
elif definition.input_type == "select" and raw not in options_for(definition):
raise ValueError
except (ValueError, InvalidOperation):
raise HTTPException(422, f"{definition.label}:数据格式不符合当前字段类型")
if len(raw) > 10000:
raise HTTPException(422, f"{definition.label}:内容不能超过 10000 个字符")
return raw
def validate_custom_values(db: Session, submitted: dict[str, Any], existing_record: MaterialRecord | None = None) -> dict[CustomFieldDefinition, str | None]:
definitions = active_definitions(db)
by_key = {item.field_key: item for item in definitions}
unknown = set(submitted) - set(by_key)
if unknown:
raise HTTPException(422, "包含未知或已停用的自定义字段")
existing_keys = {value.definition.field_key for value in existing_record.custom_values} if existing_record else set()
validated: dict[CustomFieldDefinition, str | None] = {}
for definition in definitions:
if definition.field_key not in submitted and existing_record is not None:
if definition.is_required and definition.field_key not in existing_keys:
raise HTTPException(422, f"{definition.label}:该字段为必填项")
continue
validated[definition] = validate_raw_value(definition, submitted.get(definition.field_key))
return validated
def apply_custom_values(db: Session, record: MaterialRecord, values: dict[CustomFieldDefinition, str | None]) -> None:
existing = {item.field_id: item for item in record.custom_values}
for definition, raw_value in values.items():
item = existing.get(definition.id)
if raw_value is None:
if item:
db.delete(item)
continue
if item:
item.raw_value = raw_value
else:
db.add(CustomFieldValue(record_id=record.id, field_id=definition.id, raw_value=raw_value))
def render_custom_value(value: CustomFieldValue) -> dict[str, Any]:
definition, raw = value.definition, value.raw_value or ""
compatible = True
processed: Any = raw
try:
if definition.input_type == "number":
number = Decimal(raw)
if not number.is_finite():
raise ValueError
processed = float(number)
elif definition.input_type == "integer":
number = Decimal(raw)
if number != number.to_integral_value():
raise ValueError
processed = int(number)
elif definition.input_type == "date":
processed = date.fromisoformat(raw).isoformat()
elif definition.input_type == "boolean":
if raw.lower() not in {"true", "false", "1", "0"}:
raise ValueError
processed = raw.lower() in {"true", "1"}
elif definition.input_type == "select" and raw not in options_for(definition):
compatible = False
except (ValueError, InvalidOperation):
compatible = False
processed = raw
display = "是" if processed is True else "否" if processed is False else str(processed)
if definition.unit and display:
display = f"{display} {definition.unit}"
return {
"field_key": definition.field_key,
"label": definition.label,
"input_type": definition.input_type,
"unit": definition.unit,
"raw_value": raw,
"value": processed,
"display_value": display,
"compatible": compatible,
"is_active": definition.is_active,
"is_public": definition.is_public,
}
def definition_usage_counts(db: Session) -> dict[int, int]:
rows = db.execute(select(CustomFieldValue.field_id, func.count(CustomFieldValue.id)).group_by(CustomFieldValue.field_id)).all()
return {field_id: count for field_id, count in rows}