feat: P1批次1 设计域(AI推荐/分享体系/阳光房绘图/量尺标注/逐格指定编辑)
This commit is contained in:
@@ -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="推荐完成")
|
||||
@@ -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)
|
||||
|
||||
@@ -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]})
|
||||
@@ -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="已删除")
|
||||
Reference in New Issue
Block a user