feat: P1批次1 设计域(AI推荐/分享体系/阳光房绘图/量尺标注/逐格指定编辑)

This commit is contained in:
HHY
2026-09-25 06:17:29 -07:00
parent 6726a9fa3a
commit 76fd0ced14
21 changed files with 1154 additions and 20 deletions
+159
View File
@@ -0,0 +1,159 @@
"""AI 推荐窗型路由(对齐目标站 ai_recommend:按需求推荐样板,规则引擎确定性打分)。"""
import json
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.core.drawing import parse_drawing, replay_shape
from huamenchuang.models.windoor_template import WindoorTemplate
router = APIRouter(prefix="/ai-recommend", tags=["AI 推荐"])
class RecommendBody(BaseModel):
width_mm: int = Field(ge=300, le=20000, description="洞口宽度 mm")
height_mm: int = Field(ge=300, le=20000, description="洞口高度 mm")
room: str = Field(default="living", pattern="^(living|bedroom|balcony|kitchen|bath|shop)$",
description="房间: 客厅/卧室/阳台/厨房/卫浴/商铺")
needs: list[str] = Field(default_factory=list,
description="需求标签: ventilation通风/light采光/sound隔音/safe安全/view景观/door通行")
# 需求标签 → 加分特征
_TAG_RULES = {
"ventilation": ("开扇", 3.0, "需要通风,优先带开启扇"),
"light": ("玻璃面积", 2.0, "需要采光,优先大玻璃分格"),
"sound": ("隔音", 2.0, "需要隔音,优先少开扇+固定大格"),
"safe": ("安全", 1.5, "需要安全,优先内开内倒/纱扇"),
"view": ("景观", 1.5, "需要景观面,优先大板玻璃"),
"door": ("通行", 3.0, "需要通行,优先门/推拉类"),
}
_ROOM_HINT = {
"living": ("客厅", ["view", "light", "ventilation"]),
"bedroom": ("卧室", ["ventilation", "sound", "safe"]),
"balcony": ("阳台", ["door", "ventilation", "light"]),
"kitchen": ("厨房", ["ventilation", "light"]),
"bath": ("卫浴", ["ventilation", "safe"]),
"shop": ("商铺", ["door", "view", "light"]),
}
def _template_features(dd: dict) -> dict:
"""提取样板特征: 宽高比/玻璃面积比/开扇数/是否门类/是否有纱扇。"""
try:
parsed = parse_drawing(dd)
except Exception:
return {}
xs, ys, glass_area = [], [], 0.0
sashes = 0
is_door = False
has_screen = False
for shape in parsed.get("sm", []):
if not isinstance(shape, dict):
continue
regions = replay_shape(shape)
pts = [[(seg["ps"]["x"], seg["ps"]["y"]) for seg in r] for r in regions]
if not pts:
continue
flat = [p for r in pts for p in r]
xs += [min(p[0] for p in flat), max(p[0] for p in flat)]
ys += [min(p[1] for p in flat), max(p[1] for p in flat)]
for r in pts:
a = 0.0
for i in range(len(r)):
x1, y1 = r[i]
x2, y2 = r[(i + 1) % len(r)]
a += x1 * y2 - x2 * y1
glass_area += abs(a) / 2
for sub in (shape.get("sm") or []):
if not isinstance(sub, dict):
continue
t = str(sub.get("type", ""))
if "Screen" in t:
has_screen = True
continue
if "Slide" in t:
sashes += 2
elif sub.get("bd") and not (sub.get("bd") or {}).get("hidden"):
sashes += 1
if sub.get("isDoor"):
is_door = True
if not xs:
return {}
w, h = max(xs) - min(xs), max(ys) - min(ys)
frame_area = w * h
return {"ratio": w / h if h else 1, "glass_ratio": glass_area / frame_area if frame_area else 0,
"sashes": sashes, "is_door": is_door, "has_screen": has_screen}
@router.post("")
async def recommend(body: RecommendBody, user: CurrentUser, db: DbSession):
"""按洞口尺寸+房间+需求标签推荐样板 TOP5(规则打分,可解释理由)。"""
stmt = select(WindoorTemplate).where(WindoorTemplate.is_default == 1)
templates = list((await db.execute(stmt)).scalars())
if not templates:
return fail("样板库为空")
room_name, default_tags = _ROOM_HINT.get(body.room, ("客厅", ["light"]))
tags = list(dict.fromkeys((body.needs or []) + default_tags))
target_ratio = body.width_mm / body.height_mm
scored = []
for t in templates[:80]: # 打分上限控制计算量(前 80 套按 id 序,已覆盖六大类)
if not t.drawing_data:
continue
try:
dd = t.drawing_data if isinstance(t.drawing_data, dict) else json.loads(t.drawing_data)
except Exception:
continue
f = _template_features(dd)
if not f:
continue
score, reasons = 0.0, []
# 尺寸适配: 宽高比接近度(0~4 分) + 洞口在模板可调范围
r_diff = abs(f["ratio"] - target_ratio) / max(target_ratio, 0.01)
score += max(0.0, 4.0 - r_diff * 4.0)
if r_diff < 0.15:
reasons.append("宽高比例与洞口匹配")
# 标签规则
if "ventilation" in tags:
v = min(f["sashes"], 3) * 1.2
score += v
if v >= 1.2:
reasons.append(f"带 {f['sashes']} 个开启扇,通风好")
if "light" in tags:
v = f["glass_ratio"] * 4.0
score += v
if f["glass_ratio"] > 0.6:
reasons.append("大玻璃面积,采光佳")
if "sound" in tags:
if f["sashes"] <= 1:
score += 2.0
reasons.append("固定大格为主,隔音好")
if "safe" in tags:
if f["has_screen"]:
score += 1.5
reasons.append("带纱扇防蚊安全")
if "view" in tags:
if f["glass_ratio"] > 0.65 and f["sashes"] <= 2:
score += 2.0
reasons.append("大板玻璃,景观视野好")
if "door" in tags:
if f["is_door"] or f["ratio"] >= 1.2:
score += 3.0
if f["is_door"]:
reasons.append("门类窗型,可通行")
if not reasons:
reasons.append("综合分格均衡")
scored.append((score, t, reasons[:3]))
scored.sort(key=lambda x: -x[0])
items = [{
"id": t.id, "name": t.name, "pic_url": t.pic_url, "catelog_id": t.catelog_id,
"score": round(s, 2), "reasons": rs,
} for s, t, rs in scored[:5]]
return ok(data={"items": items, "room": room_name, "tags": tags,
"summary": f"按{room_name}场景({'、'.join(tags)})从 {len(scored)} 套样板中推荐"},
message="推荐完成")
+6
View File
@@ -13,8 +13,11 @@ from huamenchuang.api import (
orders,
platform,
quote,
recommend,
reports,
script,
shares,
sunroom,
warehouse,
windoor,
)
@@ -31,6 +34,9 @@ api_router.include_router(script.router)
api_router.include_router(order_format.router)
api_router.include_router(finance.router)
api_router.include_router(ai_render.router)
api_router.include_router(shares.router)
api_router.include_router(recommend.router)
api_router.include_router(sunroom.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
api_router.include_router(platform.router)
+44
View File
@@ -0,0 +1,44 @@
"""通用分享路由:渲染图/样板组合/报价单/阳光房设计分享(公开访问,HMAC token)。"""
import json
import secrets
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.extend import ShareItem
router = APIRouter(tags=["分享"])
SHARE_TYPES = ("render", "combine", "quote", "sunny")
class ShareBody(BaseModel):
type: str = Field(pattern="^(render|combine|quote|sunny)$")
title: str = Field(default="", max_length=128)
payload: dict = Field(default_factory=dict, description="render:{image}, combine:{shapes[]}, quote:{items[],total,customer}, sunny:{image,params}")
@router.post("/shares")
async def create_share(body: ShareBody, user: CurrentUser, db: DbSession):
"""生成分享链接(对齐 contract_share / share-render 语义)。"""
item = ShareItem(
company_id=user.company_id, token=secrets.token_urlsafe(24),
type=body.type, title=body.title,
payload=json.dumps(body.payload, ensure_ascii=False), user_id=user.id)
db.add(item)
await db.flush()
return ok(data={"token": item.token, "url": f"/share/s/{item.token}"}, message="分享链接已生成")
@router.get("/shares/{token}")
async def get_share(token: str, db: DbSession):
"""公开访问分享内容(无需登录,对齐 group-detail-share/:id/:user_id)。"""
item = (await db.execute(select(ShareItem).where(ShareItem.token == token))).scalar_one_or_none()
if item is None:
return fail("分享不存在或已失效")
return ok(data={"type": item.type, "title": item.title,
"payload": json.loads(item.payload or "{}"),
"created_at": str(item.created_at)[:19]})
+60
View File
@@ -0,0 +1,60 @@
"""阳光房设计路由(对齐目标站 sunnyhouse:平顶/人字顶参数化设计,出 2D 图 + AI 效果图)。"""
import json
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.extend import SunroomDesign
router = APIRouter(prefix="/sunroom", tags=["阳光房"])
class SunroomBody(BaseModel):
order_id: int | None = None
name: str = Field(default="", max_length=64)
roof: str = Field(default="flat", pattern="^(flat|gable)$")
width_mm: int = Field(default=4000, ge=1500, le=20000)
depth_mm: int = Field(default=3000, ge=1500, le=20000)
height_mm: int = Field(default=2800, ge=2000, le=6000)
bay_mm: int = Field(default=1000, ge=400, le=3000)
glass: str = Field(default="双层中空钢化玻璃 5+12A+5", max_length=64)
frame_color: str = Field(default="深灰", max_length=16)
def _to_out(d: SunroomDesign) -> dict:
return {"id": d.id, "order_id": d.order_id, "name": d.name, "roof": d.roof,
"width_mm": d.width_mm, "depth_mm": d.depth_mm, "height_mm": d.height_mm,
"bay_mm": d.bay_mm, "params": json.loads(d.params or "{}"),
"created_at": str(d.created_at)[:19]}
@router.get("/designs")
async def list_designs(user: CurrentUser, db: DbSession):
rows = list((await db.execute(select(SunroomDesign).where(
SunroomDesign.company_id == user.company_id).order_by(SunroomDesign.id.desc()))).scalars())
return ok(data=[_to_out(r) for r in rows])
@router.post("/designs")
async def save_design(body: SunroomBody, user: CurrentUser, db: DbSession):
d = SunroomDesign(company_id=user.company_id, order_id=body.order_id, name=body.name,
roof=body.roof, width_mm=body.width_mm, depth_mm=body.depth_mm,
height_mm=body.height_mm, bay_mm=body.bay_mm,
params=json.dumps({"glass": body.glass, "frame_color": body.frame_color},
ensure_ascii=False),
user_id=user.id)
db.add(d)
await db.flush()
return ok(data=_to_out(d), message="设计已保存")
@router.delete("/designs/{design_id}")
async def delete_design(design_id: int, user: CurrentUser, db: DbSession):
d = await db.get(SunroomDesign, design_id)
if d is None or d.company_id != user.company_id:
return fail("设计不存在")
await db.delete(d)
return ok(message="已删除")
+4
View File
@@ -22,7 +22,9 @@ from huamenchuang.models.extend import (
Device,
OrderFormat,
PayOrder,
ShareItem,
SmsCode,
SunroomDesign,
)
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess
from huamenchuang.models.user import User
@@ -49,7 +51,9 @@ __all__ = [
"ProductCatelog",
"Quotation",
"ReceiptAccount",
"ShareItem",
"SmsCode",
"SunroomDesign",
"StockMove",
"User",
"WarehouseItem",
+35
View File
@@ -84,6 +84,41 @@ class OrderFormat(Base):
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class ShareItem(Base):
"""通用分享项(对齐目标站 share-render/group-detail-share/报价链接:HMAC token 公开访问)。"""
__tablename__ = "share_items"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
token: Mapped[str] = mapped_column(String(48), unique=True, index=True)
# render=渲染图 combine=样板组合 quote=报价单 sunny=阳光房设计
type: Mapped[str] = mapped_column(String(16), index=True)
title: Mapped[str] = mapped_column(String(128), default="")
payload: Mapped[str] = mapped_column(Text, default="{}") # 类型化 JSON(图片 dataURL / 组合图纸 / 报价明细)
user_id: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class SunroomDesign(Base):
"""阳光房设计(对齐目标站 sunnyhouse 模块:平顶/人字顶参数化 + 效果图)。"""
__tablename__ = "sunroom_designs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
order_id: Mapped[int | None] = mapped_column(default=None, index=True) # 关联阳光房订单
name: Mapped[str] = mapped_column(String(64), default="")
roof: Mapped[str] = mapped_column(String(8), default="flat") # flat平顶 / gable人字顶
width_mm: Mapped[int] = mapped_column(default=4000) # 宽(X向)
depth_mm: Mapped[int] = mapped_column(default=3000) # 进深(Y向)
height_mm: Mapped[int] = mapped_column(default=2800) # 柱高/檐高
bay_mm: Mapped[int] = mapped_column(default=1000) # 立柱间距
params: Mapped[str] = mapped_column(Text, default="{}") # 扩展(玻璃规格/立柱色等)
user_id: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class SmsCode(Base):
"""短信验证码(provider 适配层:未配置短信签名时走 mock 通道)。"""
+22 -3
View File
@@ -41,6 +41,12 @@ QUICK_SCENE = {
"studio": "studio product shot on a seamless neutral background, softbox lighting, slight floor reflection",
}
# 阳光房场景(对齐目标站 sunnyhouse 平顶/人字顶)
SUNROOM_DESC = {
"sunroom-flat": "a flat-roof glass sunroom attached to the house, slim {frame_word} aluminum posts and glass beams, clear tempered glass roof panels, furnished with rattan chairs and plants, garden view",
"sunroom-gable": "a gable (A-frame pitched) glass sunroom attached to the house, ridge beam with fan-shaped front glass, {frame_word} aluminum structure, clear glass roof, garden view",
}
def _sash_kind(sub: dict) -> str | None:
t = str(sub.get("type", ""))
@@ -134,12 +140,25 @@ def build_prompt(drawing: str | dict, style: str = "modern", time: str = "day",
ratio: portrait/landscape/square → 依据窗型宽高比的构图引导。
"""
info = describe_window(drawing)
dd = parse_drawing(drawing)
finish, style_word = STYLE_FINISH.get(style, STYLE_FINISH["modern"])
light = TIME_LIGHT.get(time, TIME_LIGHT["day"])
scene_tpl = SCENE_DESC.get(scene) or QUICK_SCENE.get(scene) or SCENE_DESC["render"]
scene_text = scene_tpl.replace("{style_word}", style_word)
window_desc = info["summary"] or "a modern aluminum window"
# 阳光房场景: 窗型描述让位于结构描述
frame_word = "anthracite" if style == "modern" else ("bronze" if style == "chinese" else "white")
if scene in SUNROOM_DESC:
scene_text = SUNROOM_DESC[scene].replace("{frame_word}", frame_word)
sr = dd.get("_sunroom") or {}
fw = sr.get("width_mm") or info["width_mm"] or 4000
fd = sr.get("depth_mm") or 3000
fh = sr.get("height_mm") or 2800
bays = max(2, round(fw / (sr.get("bay_mm") or 1000)))
window_desc = (f"Structure: {scene_text}; footprint {fw}×{fd}mm, eave height {fh}mm, "
f"{bays} bays with slim aluminum posts")
else:
scene_tpl = SCENE_DESC.get(scene) or QUICK_SCENE.get(scene) or SCENE_DESC["render"]
scene_text = scene_tpl.replace("{style_word}", style_word)
window_desc = info["summary"] or "a modern aluminum window"
composition = {
"portrait": "vertical composition, window fills most of the frame height",
"landscape": "horizontal composition, generous wall context around the window",
+21 -2
View File
@@ -27,6 +27,7 @@ class TakeoffResult:
width_mm: int = 0
height_mm: int = 0
sash_count: int = 0
sash_assign_count: int = 0
meters: dict = field(default_factory=dict) # 各型材类别总米数 {frame, mullion, sash}
@@ -106,6 +107,23 @@ def takeoff(dd) -> TakeoffResult:
if cols:
result.bars.append(BarItem("dec_bar", round(bb[3] - bb[1]), cols, "格条-竖"))
# 逐格指定(自研 assigns 扩展): 开扇五金+扇框料 / 副梃
for assign in (shape.get("assigns") or []):
if not isinstance(assign, dict):
continue
ri = int(assign.get("ri", -1))
if not (0 <= ri < len(region_bbs)):
continue
bb = region_bbs[ri]
w, h = round(bb[2] - bb[0]), round(bb[3] - bb[1])
sash = assign.get("sash") or {}
if sash.get("kind") and sash["kind"] != "fixed":
result.bars.append(BarItem("sash", w, 2, "指定扇-宽"))
result.bars.append(BarItem("sash", h, 2, "指定扇-高"))
result.sash_assign_count += 1
if assign.get("subBar"):
result.bars.append(BarItem("subbar", w, 1, "副梃"))
# 扇框
for sub in (shape.get("sm") or []):
bd = sub.get("bd") if isinstance(sub, dict) else None
@@ -127,7 +145,7 @@ def takeoff(dd) -> TakeoffResult:
result.sash_count = sum(
1 for s in dd.get("sm", []) for sub in (s.get("sm") or [])
if isinstance(sub, dict) and sub.get("bd") and not sub.get("bd").get("hidden")
)
) + result.sash_assign_count
return result
@@ -167,7 +185,8 @@ def quote(drawing: str | dict, prices: dict | None = None, scripts: dict | None
"""
from huamenchuang.services.formula import FormulaError, eval_formula
p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180, "dec_bar": 12}
p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180, "dec_bar": 12,
"subbar": 25}
if prices:
p.update(prices)