公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
123 lines
5.1 KiB
Python
123 lines
5.1 KiB
Python
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
|