Files
my-v0-server/app/schemas/actions/layout.py
T
2026-10-08 00:28:29 -07:00

119 lines
4.6 KiB
Python

"""布局意图:模型只说“怎么排”,坐标由后端算,展开成 move 和 resize 后才交给前端
这些动作只存在于模型提交的这一步,不会出现在返回给前端的动作里,前端协议不变
"""
from typing import Annotated, Literal, Self
from pydantic import Field, model_validator
from app.schemas.screen import Number, PositiveNumber
from .base import ActionModel, BaseAction
NonNegativeNumber = Annotated[Number, Field(ge=0)]
TargetIds = Annotated[
list[str],
Field(min_length=1, description="要排列的组件 ID,不能重复"),
]
class Area(ActionModel):
"""画布上的一块矩形区域,坐标单位 px"""
x: Number = Field(description="左上角横坐标")
y: Number = Field(description="左上角纵坐标")
width: PositiveNumber
height: PositiveNumber
class _Group(ActionModel):
target_ids: TargetIds
@model_validator(mode="after")
def unique_ids(self) -> Self:
if len(set(self.target_ids)) != len(self.target_ids):
raise ValueError("target_ids 不能重复")
return self
class ArrangeAction(_Group):
"""把一组组件平分一块区域:每个组件的位置和尺寸都由这里决定,彼此不重叠、不超出区域
用于“平分空间”“排成一行/一列”“两行两列铺满”等;按 target_ids 的顺序从左到右、从上到下放置
"""
type: Literal["arrange"] = "arrange"
mode: Literal["row", "column", "grid"] = Field(
description="row:排成一行;column:排成一列;grid:按 columns 列排成网格"
)
columns: int | None = Field(
default=None, ge=1, description="mode=grid 时每行几个,其他模式不填"
)
area: Area | None = Field(
default=None, description="要铺满的区域,省略表示整个画布"
)
gap: NonNegativeNumber = Field(default=20, description="组件之间的间距,单位 px")
padding: NonNegativeNumber = Field(
default=0, description="区域四周留出的边距,单位 px"
)
@model_validator(mode="after")
def columns_match_mode(self) -> Self:
if (self.mode == "grid") != (self.columns is not None):
raise ValueError("mode=grid 时必须填写 columns,其他模式不填")
return self
class AlignAction(_Group):
"""按基准对齐,不改变尺寸;居中、靠左、靠右、顶部对齐等"""
type: Literal["align"] = "align"
to: Literal["canvas", "selection"] = Field(
default="canvas",
description="canvas:对齐到画布;selection:对齐到这些组件合起来的外框",
)
horizontal: Literal["left", "center", "right"] | None = Field(
default=None, description="水平方向的对齐方式,省略表示不调整水平位置"
)
vertical: Literal["top", "middle", "bottom"] | None = Field(
default=None, description="垂直方向的对齐方式,省略表示不调整垂直位置"
)
@model_validator(mode="after")
def require_direction(self) -> Self:
if self.horizontal is None and self.vertical is None:
raise ValueError("align 至少提供 horizontal 或 vertical")
return self
class DistributeAction(_Group):
"""沿一个方向等间距排开,不改变尺寸;按当前位置的先后顺序排列"""
type: Literal["distribute"] = "distribute"
axis: Literal["horizontal", "vertical"]
gap: NonNegativeNumber | None = Field(
default=None,
description="固定间距,单位 px,从区域起点(没有 area 时是最前一个组件的位置)依次排开;"
"省略表示把间距摊匀,首尾两个组件的外沿不动",
)
area: Area | None = Field(
default=None, description="在这块区域内排开,省略表示用这些组件合起来的外框"
)
class SetLayoutAction(BaseAction):
"""直接设置组件的绝对位置和尺寸,只写要改的项,其余保持不变;不需要自己换算位移"""
type: Literal["set_layout"] = "set_layout"
x: Number | None = Field(default=None, description="目标左上角横坐标")
y: Number | None = Field(default=None, description="目标左上角纵坐标")
width: PositiveNumber | None = Field(default=None, description="目标宽度")
height: PositiveNumber | None = Field(default=None, description="目标高度")
@model_validator(mode="after")
def require_field(self) -> Self:
if all(v is None for v in (self.x, self.y, self.width, self.height)):
raise ValueError("set_layout 至少提供 x、y、width、height 中的一项")
return self