feat: v0.7 全功能复刻与前端适配

后端:
- 产品价格本模块(型材/玻璃/五金/配件, 供报价取价)
- 报价单模块(BJ单号, 手动报价/样板算料/转订单)
- 售后单(问题描述必填, 对齐原站校验)与状态流转
- 量尺单(洞口尺寸清单)与阳光房订单(order_type=1)
- 个人中心: 资料/密码修改/邀请返利(HMC邀请码)
前端:
- 新增8页面: 产品/报价单/售后/量尺/阳光房/商城/使用指南/个人中心
- 登录页注册双Tab, 响应式侧边栏(<900px自动折叠)
- Element Plus 图标全局注册修复
- 共15个页面覆盖原站核心业务路由
This commit is contained in:
HHY
2026-09-20 11:53:31 -07:00
parent 78563267c4
commit b2730af80b
22 changed files with 1404 additions and 29 deletions
+48 -1
View File
@@ -1,5 +1,6 @@
"""认证路由:登录/注册/当前用户。"""
"""认证路由:登录/注册/当前用户/个人中心。"""
from fastapi import APIRouter
from pydantic import BaseModel
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
@@ -42,8 +43,54 @@ async def register(body: LoginRequest, db: DbSession):
@router.get("/current-user")
async def current_user(user: CurrentUser):
"""当前登录用户信息。"""
from huamenchuang.core.config import settings
return ok(data={
"id": user.id, "nickname": user.nickname, "phone_number": user.phone_number,
"company_id": user.company_id, "language": user.language,
"enabled_modules": settings.enabled_modules,
"try_expiration_time": str(user.try_expiration_time) if user.try_expiration_time else None,
})
class ProfileBody(BaseModel):
nickname: str | None = None
email: str | None = None
class PasswordBody(BaseModel):
old_password: str
new_password: str
@router.put("/profile")
async def update_profile(body: ProfileBody, user: CurrentUser, db: DbSession):
"""修改个人资料(对齐原站 personal-center)。"""
from huamenchuang.services.auth_service import hash_password # noqa: F401
if body.nickname is not None:
user.nickname = body.nickname[:64]
if body.email is not None:
user.email = body.email[:128]
return ok(message="资料已更新")
@router.put("/password")
async def change_password(body: PasswordBody, user: CurrentUser, db: DbSession):
"""修改密码(校验旧密码)。"""
from huamenchuang.services.auth_service import hash_password, verify_password
if not verify_password(body.old_password, user.password_hash):
return fail("旧密码错误")
if len(body.new_password) < 4:
return fail("新密码至少4位")
user.password_hash = hash_password(body.new_password)
return ok(message="密码已修改")
@router.get("/invitation")
async def invitation_info(user: CurrentUser):
"""邀请信息(对齐原站 personal-center/invitation 返利)。"""
return ok(data={
"invite_code": f"HMC{user.id:06d}",
"invited_count": 0,
"bounty": "0.00",
"rule": "好友通过邀请码注册并购买后,可获得相应奖励",
})
+331
View File
@@ -0,0 +1,331 @@
"""业务扩展路由:产品价格本、报价单、售后、量尺、阳光房订单过滤。"""
import datetime
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from huamenchuang.core.database import SessionLocal
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.models.business import AfterSale, MeasureOrder, Product, Quotation
from huamenchuang.models.order import Order
router = APIRouter(tags=["业务扩展"])
# ---------------- 产品价格本 ----------------
class ProductBody(BaseModel):
name: str = Field(min_length=1, max_length=128)
material: str = Field(default="profile", pattern="^(profile|glass|hardware|addon)$")
spec: str = ""
unit: str = "米"
price: float = 0
cost: float = 0
note: str = ""
enabled: int = 1
@router.get("/products")
async def list_products(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
keyword: str = "", material: str | None = None):
conds = [Product.company_id == user.company_id]
if material:
conds.append(Product.material == material)
if kw := keyword.strip():
like = f"%{kw}%"
conds.append(or_(Product.name.like(like), Product.spec.like(like)))
where = *conds,
total = (await db.execute(select(func.count()).select_from(Product).where(*where))).scalar_one()
stmt = (select(Product).where(*where).order_by(Product.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([{
"id": p.id, "name": p.name, "material": p.material, "spec": p.spec, "unit": p.unit,
"price": float(p.price), "cost": float(p.cost), "enabled": p.enabled,
"note": p.note,
} for p in items], page, page_size, total))
@router.post("/products")
async def create_product(body: ProductBody, user: CurrentUser, db: DbSession):
p = Product(company_id=user.company_id, **body.model_dump())
db.add(p)
await db.flush()
return ok(data={"id": p.id}, message="创建成功")
@router.put("/products/{pid}")
async def update_product(pid: int, body: ProductBody, user: CurrentUser, db: DbSession):
p = await db.get(Product, pid)
if p is None or p.company_id != user.company_id:
return fail("产品不存在")
for k, v in body.model_dump().items():
setattr(p, k, v)
return ok(message="更新成功")
@router.delete("/products/{pid}")
async def delete_product(pid: int, user: CurrentUser, db: DbSession):
p = await db.get(Product, pid)
if p is None or p.company_id != user.company_id:
return fail("产品不存在")
await db.delete(p)
return ok(message="删除成功")
# ---------------- 报价单 ----------------
class QuotationBody(BaseModel):
customer_id: int | None = None
customer_name: str = ""
contract_id: int | None = None
template_id: int | None = None
drawing_data: str = ""
items: list[dict] = []
glass_area: float = 0
width_mm: int = 0
height_mm: int = 0
total: float = 0
note: str = ""
status: int = Field(default=0, ge=0, le=3)
async def _next_quote_no(db, company_id: int) -> str:
today = datetime.date.today().strftime("%Y%m%d")
prefix = f"BJ{today}"
stmt = select(Quotation.quote_no).where(
Quotation.company_id == company_id, Quotation.quote_no.like(f"{prefix}%")
).order_by(Quotation.quote_no.desc()).limit(1)
import json as _json
last = (await db.execute(stmt)).scalar_one_or_none()
seq = int(last[-3:]) + 1 if last else 1
return f"{prefix}{seq:03d}"
def _quote_out(q: Quotation) -> dict:
return {
"id": q.id, "quote_no": q.quote_no, "customer_id": q.customer_id,
"customer_name": q.customer_name, "contract_id": q.contract_id,
"template_id": q.template_id, "glass_area": float(q.glass_area),
"width_mm": q.width_mm, "height_mm": q.height_mm, "total": float(q.total),
"status": q.status, "note": q.note, "created_at": str(q.created_at),
}
@router.post("/quotations")
async def create_quotation(body: QuotationBody, user: CurrentUser, db: DbSession):
q = Quotation(
company_id=user.company_id, user_id=user.id,
quote_no=await _next_quote_no(db, user.company_id),
items=__import__("json").dumps(body.items, ensure_ascii=False),
**{k: v for k, v in body.model_dump().items() if k != "items"},
)
db.add(q)
await db.flush()
return ok(data=_quote_out(q), message="报价单已保存")
@router.get("/quotations")
async def list_quotations(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
keyword: str = "", status: int | None = None):
conds = [Quotation.company_id == user.company_id]
if status is not None:
conds.append(Quotation.status == status)
if kw := keyword.strip():
like = f"%{kw}%"
conds.append(or_(Quotation.quote_no.like(like), Quotation.customer_name.like(like)))
where = *conds,
total = (await db.execute(select(func.count()).select_from(Quotation).where(*where))).scalar_one()
stmt = (select(Quotation).where(*where).order_by(Quotation.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([_quote_out(q) for q in items], page, page_size, total))
@router.get("/quotations/{qid}")
async def quotation_detail(qid: int, user: CurrentUser, db: DbSession):
q = await db.get(Quotation, qid)
if q is None or q.company_id != user.company_id:
return fail("报价单不存在")
import json as _json
d = _quote_out(q)
d["items"] = _json.loads(q.items or "[]")
d["drawing_data"] = q.drawing_data
return ok(data=d)
@router.put("/quotations/{qid}")
async def update_quotation(qid: int, body: QuotationBody, user: CurrentUser, db: DbSession):
q = await db.get(Quotation, qid)
if q is None or q.company_id != user.company_id:
return fail("报价单不存在")
for k, v in body.model_dump().items():
if k == "items":
q.items = __import__("json").dumps(v, ensure_ascii=False)
else:
setattr(q, k, v)
return ok(data=_quote_out(q), message="更新成功")
@router.delete("/quotations/{qid}")
async def delete_quotation(qid: int, user: CurrentUser, db: DbSession):
q = await db.get(Quotation, qid)
if q is None or q.company_id != user.company_id:
return fail("报价单不存在")
await db.delete(q)
return ok(message="删除成功")
# ---------------- 售后单 ----------------
class AfterSaleBody(BaseModel):
title: str = ""
description: str = Field(min_length=1, description="问题描述(必填,对齐原站校验)")
order_id: int | None = None
order_no: str = ""
status: int = Field(default=0, ge=0, le=2)
result: str = ""
@router.post("/after_sales")
async def create_after_sale(body: AfterSaleBody, user: CurrentUser, db: DbSession):
a = AfterSale(company_id=user.company_id, user_id=user.id, **body.model_dump())
db.add(a)
await db.flush()
return ok(data={"id": a.id}, message="售后单已提交")
@router.get("/after_sales")
async def list_after_sales(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
status: int | None = None):
conds = [AfterSale.company_id == user.company_id]
if status is not None:
conds.append(AfterSale.status == status)
where = *conds,
total = (await db.execute(select(func.count()).select_from(AfterSale).where(*where))).scalar_one()
stmt = (select(AfterSale).where(*where).order_by(AfterSale.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([{
"id": a.id, "title": a.title, "description": a.description, "order_id": a.order_id,
"order_no": a.order_no, "status": a.status, "result": a.result,
"created_at": str(a.created_at),
} for a in items], page, page_size, total))
class AfterSaleFlowBody(BaseModel):
status: int = Field(ge=0, le=2)
result: str = ""
@router.put("/after_sales/{aid}")
async def update_after_sale(aid: int, body: AfterSaleFlowBody, user: CurrentUser, db: DbSession):
"""售后状态流转。"""
a = await db.get(AfterSale, aid)
if a is None or a.company_id != user.company_id:
return fail("售后单不存在")
a.status = body.status
if body.result:
a.result = body.result
return ok(message="状态已更新")
class MeasureUpdateBody(BaseModel):
customer_name: str | None = None
phone: str | None = None
address: str | None = None
measure_date: str | None = None
surveyor: str | None = None
status: int | None = Field(default=None, ge=0, le=2)
data: list[dict] | None = None
note: str | None = None
@router.put("/measures/{mid}")
async def update_measure(mid: int, body: MeasureUpdateBody, user: CurrentUser, db: DbSession):
"""更新量尺单。"""
m = await db.get(MeasureOrder, mid)
if m is None or m.company_id != user.company_id:
return fail("量尺单不存在")
data = body.model_dump(exclude_none=True)
md = data.pop("measure_date", None)
if md is not None:
try:
m.measure_date = datetime.datetime.fromisoformat(md)
except ValueError:
return fail("日期格式错误")
items = data.pop("data", None)
for k, v in data.items():
setattr(m, k, v)
if items is not None:
m.data = __import__("json").dumps(items, ensure_ascii=False)
return ok(message="更新成功")
# ---------------- 量尺单 ----------------
class MeasureBody(BaseModel):
customer_id: int | None = None
customer_name: str = ""
phone: str = ""
address: str = ""
measure_date: str | None = None
surveyor: str = ""
status: int = Field(default=0, ge=0, le=2)
data: list[dict] = [] # 洞口尺寸 [{name,width,height,qty}]
note: str = ""
@router.post("/measures")
async def create_measure(body: MeasureBody, user: CurrentUser, db: DbSession):
md = None
if body.measure_date:
try:
md = datetime.datetime.fromisoformat(body.measure_date)
except ValueError:
return fail("量尺日期格式错误")
m = MeasureOrder(company_id=user.company_id, customer_id=body.customer_id,
customer_name=body.customer_name, phone=body.phone, address=body.address,
measure_date=md, surveyor=body.surveyor, status=body.status,
data=__import__("json").dumps(body.data, ensure_ascii=False), note=body.note)
db.add(m)
await db.flush()
return ok(data={"id": m.id}, message="量尺单已创建")
@router.get("/measures")
async def list_measures(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
status: int | None = None):
conds = [MeasureOrder.company_id == user.company_id]
if status is not None:
conds.append(MeasureOrder.status == status)
where = *conds,
total = (await db.execute(select(func.count()).select_from(MeasureOrder).where(*where))).scalar_one()
stmt = (select(MeasureOrder).where(*where).order_by(MeasureOrder.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
import json as _json
return ok(data=paginated([{
"id": m.id, "customer_name": m.customer_name, "phone": m.phone, "address": m.address,
"measure_date": str(m.measure_date) if m.measure_date else None, "surveyor": m.surveyor,
"status": m.status, "data": _json.loads(m.data or "[]"), "note": m.note,
"created_at": str(m.created_at),
} for m in items], page, page_size, total))
# ---------------- 阳光房订单(订单按类型过滤) ----------------
@router.get("/sunny_house_orders")
async def list_sunny_orders(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15):
"""阳光房订单列表(order_type=1,对齐目标站 sunny_house_order/)。"""
conds = [Order.company_id == user.company_id, Order.order_type == 1]
total = (await db.execute(select(func.count()).select_from(Order).where(*conds))).scalar_one()
stmt = (select(Order).where(*conds).order_by(Order.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([{
"id": o.id, "order_no": o.order_no, "customer_name": o.customer_name,
"money": float(o.money), "status": o.status, "created_at": str(o.created_at),
} for o in items], page, page_size, total))
+2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter
from huamenchuang.api import (
auth,
business,
contracts,
customers,
optimize,
@@ -21,3 +22,4 @@ api_router.include_router(windoor.router)
api_router.include_router(optimize.router)
api_router.include_router(quote.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)