激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+122
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from .images import MAX_IMAGES_PER_RECORD
|
||||
|
||||
|
||||
RECORD_ONLY_FIELDS = {"custom_fields", "image_tokens", "image_ids"}
|
||||
|
||||
|
||||
class MaterialRecordInput(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
experiment_id: str = Field(min_length=1, max_length=100)
|
||||
test_date: date
|
||||
material_category: str = Field(min_length=1, max_length=100)
|
||||
material_name: str = Field(min_length=1, max_length=200)
|
||||
apparent_density: float | None = Field(default=None, ge=0)
|
||||
uv_absorption_355nm: float | None = Field(default=None, ge=0, le=100)
|
||||
material_thickness: float = Field(gt=0)
|
||||
moisture_content: float | None = Field(default=None, ge=0, le=100)
|
||||
thermal_conductivity: float | None = Field(default=None, ge=0)
|
||||
initial_decomposition_temp: float | None = None
|
||||
melting_vaporization_temp: float | None = None
|
||||
specific_heat_capacity: float | None = Field(default=None, ge=0)
|
||||
carbon_residue_rate: float | None = Field(default=None, ge=0, le=100)
|
||||
surface_roughness: float | None = Field(default=None, ge=0)
|
||||
hardness: float | None = Field(default=None, ge=0)
|
||||
actual_output_power: float = Field(ge=0)
|
||||
display_current: float = Field(ge=0)
|
||||
scanning_speed: float = Field(gt=0)
|
||||
pulse_frequency: float = Field(ge=0)
|
||||
pulse_width: float = Field(ge=0)
|
||||
defocus_amount: float
|
||||
scan_line_spacing: float = Field(gt=0)
|
||||
filling_method: str = Field(min_length=1, max_length=100)
|
||||
processing_size: str = Field(min_length=1, max_length=100)
|
||||
is_cut_through: bool
|
||||
carbonized_edge_width: float = Field(ge=0)
|
||||
etching_depth: float = Field(ge=0)
|
||||
is_fire_smolder: bool
|
||||
pattern_clarity_score: float = Field(ge=0, le=10)
|
||||
presentation_balance_score: float = Field(ge=0, le=10)
|
||||
finished_image_filename: str = Field(min_length=1, max_length=255)
|
||||
remarks: str = Field(min_length=1, max_length=5000)
|
||||
custom_fields: dict[str, str | float | int | bool | None] = Field(default_factory=dict)
|
||||
image_tokens: list[str] = Field(default_factory=list, max_length=MAX_IMAGES_PER_RECORD)
|
||||
# 省略 image_ids 表示"不动既有图片",显式传 [] 才是"全部删除"。默认值若是 [],
|
||||
# 任何不了解图片字段的旧页面或外部脚本,一次 PUT 就会静默删光整条记录的图。
|
||||
image_ids: list[int] | None = Field(default=None, max_length=MAX_IMAGES_PER_RECORD)
|
||||
|
||||
@field_validator("experiment_id", "material_category", "material_name", "filling_method", "processing_size", "finished_image_filename", "remarks")
|
||||
@classmethod
|
||||
def reject_blank(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("不能为空")
|
||||
return value.strip()
|
||||
|
||||
@field_validator("image_tokens")
|
||||
@classmethod
|
||||
def validate_image_tokens(cls, values: list[str]) -> list[str]:
|
||||
cleaned = [value.strip() for value in values if value.strip()]
|
||||
if len(cleaned) != len(set(cleaned)):
|
||||
raise ValueError("上传令牌重复")
|
||||
if any(len(value) > 64 for value in cleaned):
|
||||
raise ValueError("上传令牌格式错误")
|
||||
return cleaned
|
||||
|
||||
@field_validator("image_ids")
|
||||
@classmethod
|
||||
def validate_image_ids(cls, values: list[int] | None) -> list[int] | None:
|
||||
if values is None:
|
||||
return None
|
||||
if len(values) != len(set(values)):
|
||||
raise ValueError("图片编号重复")
|
||||
if any(value <= 0 for value in values):
|
||||
raise ValueError("图片编号无效")
|
||||
return values
|
||||
|
||||
@model_validator(mode="after")
|
||||
def limit_total_images(self):
|
||||
if len(self.image_tokens) + len(self.image_ids or []) > MAX_IMAGES_PER_RECORD:
|
||||
raise ValueError(f"每条记录最多保留 {MAX_IMAGES_PER_RECORD} 张图片")
|
||||
return self
|
||||
|
||||
|
||||
class EntryRecordSubmission(MaterialRecordInput):
|
||||
confirm_username: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
FieldInputType = Literal["text", "long_text", "number", "integer", "date", "boolean", "select"]
|
||||
|
||||
|
||||
class CustomFieldDefinitionInput(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
label: str = Field(min_length=1, max_length=100)
|
||||
input_type: FieldInputType
|
||||
is_required: bool = False
|
||||
is_active: bool = True
|
||||
is_public: bool = True
|
||||
unit: str | None = Field(default=None, max_length=50)
|
||||
options: list[str] = Field(default_factory=list, max_length=50)
|
||||
sort_order: int = Field(default=0, ge=0, le=10000)
|
||||
|
||||
@field_validator("options")
|
||||
@classmethod
|
||||
def validate_options(cls, values: list[str]) -> list[str]:
|
||||
cleaned = [value.strip() for value in values if value.strip()]
|
||||
if len(cleaned) != len(set(cleaned)):
|
||||
raise ValueError("选项不能重复")
|
||||
if any(len(value) > 100 for value in cleaned):
|
||||
raise ValueError("单个选项不能超过 100 个字符")
|
||||
return cleaned
|
||||
|
||||
@field_validator("unit")
|
||||
@classmethod
|
||||
def normalize_unit(cls, value: str | None) -> str | None:
|
||||
return value.strip() or None if value is not None else None
|
||||
Reference in New Issue
Block a user