> = {
+ day: {},
+ dusk: { indoor: ['#f0a56a', '#f6c98f', '#fde8c8'], outdoor: ['#e88a4f', '#f4b878', '#fce4bd'], studio: ['#e8b585', '#f4d3a8', '#fdf0d8'] },
+ night: { indoor: ['#1d2b45', '#2c3e5f', '#48597a'], outdoor: ['#152238', '#22344f', '#3a4d6e'], studio: ['#26303f', '#39465a', '#525f74'] },
+}
+
+function rng(seed: number) {
+ let s = seed % 2147483647
+ if (s <= 0) s += 2147483646
+ return () => (s = s * 16807 % 2147483647) / 2147483647
+}
+
+export function renderAI(
+ cv: HTMLCanvasElement, drawingData: string | object,
+ style = 'modern', time = 'day', scene = 'indoor', seed = 1,
+): void {
+ const wrap = cv.parentElement
+ if (!wrap) return
+ const dpr = window.devicePixelRatio || 1
+ cv.width = wrap.clientWidth * dpr
+ cv.height = wrap.clientHeight * dpr
+ const ctx = cv.getContext('2d')!
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
+ const W = wrap.clientWidth, H = wrap.clientHeight
+ if (!W || !H) return
+
+ const pal = PRESETS[style] ?? PRESETS.modern
+ const rand = rng(seed * 7919 + 13)
+
+ // 背景(天空)
+ const sky = TIME_SKY[time]?.[scene] ?? pal.sky
+ const bg = ctx.createLinearGradient(0, 0, 0, H)
+ bg.addColorStop(0, sky[0]); bg.addColorStop(0.6, sky[1]); bg.addColorStop(1, sky[2])
+ ctx.fillStyle = bg
+ ctx.fillRect(0, 0, W, H)
+
+ // 墙面 + 地板
+ const floorY = H * 0.68
+ const wall = ctx.createLinearGradient(0, 0, 0, floorY)
+ wall.addColorStop(0, pal.wall[0]); wall.addColorStop(1, pal.wall[1])
+ ctx.fillStyle = wall
+ ctx.fillRect(0, 0, W, floorY)
+ const floor = ctx.createLinearGradient(0, floorY, 0, H)
+ floor.addColorStop(0, pal.floor[0]); floor.addColorStop(1, pal.floor[1])
+ ctx.fillStyle = floor
+ ctx.beginPath()
+ ctx.moveTo(0, floorY); ctx.lineTo(W, floorY)
+ ctx.lineTo(W * 0.92, H); ctx.lineTo(W * 0.08, H)
+ ctx.closePath(); ctx.fill()
+ ctx.strokeStyle = 'rgba(0,0,0,0.08)'
+ ctx.lineWidth = 1
+ for (let i = 1; i < 8; i++) {
+ ctx.beginPath()
+ ctx.moveTo(W * (i / 8), floorY)
+ ctx.lineTo(W * (0.08 + 0.84 * (i / 8)), H)
+ ctx.stroke()
+ }
+ ctx.fillStyle = 'rgba(0,0,0,0.10)'
+ ctx.fillRect(0, floorY - 6, W, 6)
+
+ // 窗型几何
+ let dd: DrawingData
+ try { dd = parseDrawing(drawingData) } catch { return }
+ if (!dd?.sm?.length) return
+ const pts = dd.sm.flatMap(s => replayShape(s).regions.flat().flatMap(seg => [seg.ps, seg.pe]))
+ if (!pts.length) return
+ const gb = bboxOf(pts)
+ const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
+ const scale = Math.min(W * 0.56 / gw, H * 0.52 / gh)
+ const ox = (W - gw * scale) / 2
+ const oy = floorY - gh * scale - H * 0.12
+ const tx = (x: number) => ox + (x - gb.x0) * scale
+ const ty = (y: number) => oy + (gb.y1 - y) * scale
+
+ // 窗洞
+ ctx.save()
+ ctx.shadowColor = 'rgba(0,0,0,0.4)'
+ ctx.shadowBlur = 30
+ ctx.fillStyle = time === 'night' ? '#16233a' : sky[1]
+ ctx.fillRect(tx(gb.x0) - 10, ty(gb.y1) - 10, gw * scale + 20, gh * scale + 20)
+ ctx.restore()
+
+ // 玻璃
+ for (const shape of dd.sm) {
+ for (const region of replayShape(shape).regions) {
+ const rpts = regionPoints(region)
+ const bb = bboxOf(rpts)
+ const gx = tx(bb.x0), gy = ty(bb.y1), w = (bb.x1 - bb.x0) * scale, h = (bb.y1 - bb.y0) * scale
+ const g = ctx.createLinearGradient(gx, gy, gx, gy + h)
+ g.addColorStop(0, sky[0]); g.addColorStop(0.55, sky[1]); g.addColorStop(1, sky[2])
+ ctx.save()
+ ctx.beginPath()
+ rpts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
+ ctx.closePath()
+ ctx.clip()
+ ctx.fillStyle = g
+ ctx.fillRect(gx, gy, w, h)
+ // 远景剪影
+ ctx.fillStyle = time === 'night' ? 'rgba(10,18,32,0.55)' : 'rgba(90,110,130,0.28)'
+ let x = gx
+ while (x < gx + w) {
+ const bw = 14 + rand() * 26, bh = (10 + rand() * 0.5 * h) * (time === 'night' ? 0.7 : 0.45)
+ ctx.fillRect(x, gy + h - bh, bw, bh)
+ x += bw + 4 + rand() * 10
+ }
+ // 光束
+ if (time !== 'dusk') {
+ const ray = ctx.createLinearGradient(gx, gy, gx + w * 0.7, gy + h)
+ ray.addColorStop(0, time === 'day' ? 'rgba(255,244,200,0.5)' : 'rgba(120,160,255,0.25)')
+ ray.addColorStop(1, 'rgba(255,255,255,0)')
+ ctx.fillStyle = ray
+ ctx.beginPath()
+ ctx.moveTo(gx, gy); ctx.lineTo(gx + w * 0.55, gy)
+ ctx.lineTo(gx + w * 0.2, gy + h); ctx.lineTo(gx - w * 0.05, gy + h)
+ ctx.closePath(); ctx.fill()
+ }
+ // 反光斜带
+ ctx.globalAlpha = 0.14
+ ctx.fillStyle = '#ffffff'
+ ctx.beginPath()
+ ctx.moveTo(gx + w * 0.12, gy); ctx.lineTo(gx + w * 0.3, gy)
+ ctx.lineTo(gx - w * 0.05, gy + h); ctx.lineTo(gx - w * 0.22, gy + h)
+ ctx.closePath(); ctx.fill()
+ ctx.globalAlpha = 1
+ ctx.restore()
+ ctx.strokeStyle = pal.frame
+ ctx.lineWidth = 2.4
+ ctx.stroke()
+ }
+ }
+ // 外框
+ ctx.strokeStyle = pal.frame
+ ctx.lineWidth = 7
+ ctx.strokeRect(tx(gb.x0) - 4, ty(gb.y1) - 4, gw * scale + 8, gh * scale + 8)
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)'
+ ctx.lineWidth = 1.4
+ ctx.strokeRect(tx(gb.x0) - 1, ty(gb.y1) - 1, gw * scale + 2, gh * scale + 2)
+
+ // 地板光束
+ if (time !== 'dusk') {
+ const ray = ctx.createLinearGradient(tx(gb.x0), floorY, tx(gb.x0) + gw * scale * 0.4, H)
+ ray.addColorStop(0, time === 'day' ? 'rgba(255,240,190,0.30)' : 'rgba(140,170,255,0.12)')
+ ray.addColorStop(1, 'rgba(255,255,255,0)')
+ ctx.fillStyle = ray
+ ctx.beginPath()
+ ctx.moveTo(tx(gb.x0), oy); ctx.lineTo(tx(gb.x1), oy)
+ ctx.lineTo(tx(gb.x1) + W * 0.12, H); ctx.lineTo(tx(gb.x0) - W * 0.05, H)
+ ctx.closePath(); ctx.fill()
+ }
+
+ // 家具剪影
+ const sil = time === 'night' ? 'rgba(16,24,40,0.82)' : 'rgba(60,70,80,0.55)'
+ ctx.fillStyle = sil
+ const sy = floorY - H * 0.02
+ ctx.beginPath()
+ ctx.roundRect(W * 0.06, sy - H * 0.10, W * 0.16, H * 0.10, 8)
+ ctx.roundRect(W * 0.05, sy - H * 0.16, W * 0.035, H * 0.10, 6)
+ ctx.fill()
+ const px = W * 0.88, py = floorY + 4
+ ctx.strokeStyle = 'rgba(70,60,45,0.7)'
+ ctx.lineWidth = 4
+ ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, py - H * 0.05); ctx.stroke()
+ ctx.fillStyle = pal.accent
+ for (let i = 0; i < 6; i++) {
+ const a = -Math.PI / 2 + (i - 2.5) * 0.45 + rand() * 0.1
+ const L = H * (0.07 + rand() * 0.045)
+ ctx.beginPath()
+ ctx.ellipse(px + Math.cos(a) * L * 0.5, py - H * 0.05 + Math.sin(a) * L * 0.5, L * 0.34, L * 0.13, a, 0, Math.PI * 2)
+ ctx.fill()
+ }
+ if (style === 'chinese') {
+ ctx.fillStyle = 'rgba(150,60,45,0.75)'
+ ctx.fillRect(W * 0.14, H * 0.06, W * 0.05, H * 0.2)
+ ctx.strokeStyle = 'rgba(60,40,30,0.8)'; ctx.lineWidth = 3
+ ctx.strokeRect(W * 0.14, H * 0.06, W * 0.05, H * 0.2)
+ } else if (style === 'european') {
+ ctx.fillStyle = 'rgba(214,196,158,0.85)'
+ ctx.fillRect(W * 0.03, 0, W * 0.06, floorY * 0.9)
+ ctx.fillRect(W * 0.91, 0, W * 0.06, floorY * 0.9)
+ } else if (style === 'nordic') {
+ ctx.fillStyle = 'rgba(200,214,206,0.6)'
+ ctx.beginPath(); ctx.roundRect(W * 0.72, floorY - H * 0.08, W * 0.1, H * 0.08, 6); ctx.fill()
+ }
+
+ // 暗角
+ const vig = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.45, W / 2, H / 2, Math.max(W, H) * 0.75)
+ vig.addColorStop(0, 'rgba(0,0,0,0)')
+ vig.addColorStop(1, time === 'night' ? 'rgba(0,0,20,0.45)' : 'rgba(0,0,0,0.22)')
+ ctx.fillStyle = vig
+ ctx.fillRect(0, 0, W, H)
+}
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index a6c7968..70e4e91 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -25,6 +25,7 @@ const router = createRouter({
{ path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } },
{ path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
{ path: 'rendering', component: () => import('../views/RenderView.vue'), meta: { title: '效果图渲染' } },
+ { path: 'ai_render', component: () => import('../views/AIRenderView.vue'), meta: { title: 'AI 炫图' } },
{ path: 'threed', component: () => import('../views/RenderView.vue'), meta: { title: '3D 展示' } },
{ path: 'printer', component: () => import('../views/PrinterView.vue'), meta: { title: '打印中心' } },
{ path: 'cut_solution', component: () => import('../views/CutSolutionView.vue'), meta: { title: '下料方案' } },
diff --git a/frontend/src/views/AIRenderView.vue b/frontend/src/views/AIRenderView.vue
new file mode 100644
index 0000000..c38e7cd
--- /dev/null
+++ b/frontend/src/views/AIRenderView.vue
@@ -0,0 +1,147 @@
+
+
+
+
+ ✦ AI 炫图
+ 选择样板 → 设定风格 → 一键生成多方案效果图
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ generating ? '生成中…' : '✦ 生成 AI 炫图' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/LayoutView.vue b/frontend/src/views/LayoutView.vue
index 946ead1..b4ffa6b 100644
--- a/frontend/src/views/LayoutView.vue
+++ b/frontend/src/views/LayoutView.vue
@@ -19,6 +19,7 @@
优化下料
下料方案
效果图渲染
+ AI 炫图
3D 展示
打印中心
设备管理
diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue
index dd19d68..dec2ee9 100644
--- a/frontend/src/views/LoginView.vue
+++ b/frontend/src/views/LoginView.vue
@@ -7,47 +7,40 @@
-
+
+
+
-
+
+
+
- 登 录
- 验证码登录
-
-
-
-
-
-
-
-
-
-
-
- {{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
-
-
- 登 录
- 开发提示:mock 验证码 {{ mockCode }}(见服务端日志)
+ 登 录
-
+
+
+
-
+
+
+
-
+
+
+
- 注 册
+ 注 册
- 开发账号:13800000000 / 123456 | 忘记密码请联系管理员
+ 开发账号:13800000000 / 123456
@@ -65,33 +58,6 @@ const loading = ref(false)
const tab = ref('login')
const loginForm = reactive({ phone_number: '', password: '' })
const regForm = reactive({ phone_number: '', password: '', invite_code: '' })
-const codeForm = reactive({ phone_number: '', code: '' })
-const countdown = ref(0)
-const mockCode = ref('')
-let cdTimer: number | undefined
-
-async function sendCode() {
- if (!codeForm.phone_number) return ElMessage.warning('请输入手机号')
- await api('post', '/api/verification-code-new', { phone_number: codeForm.phone_number, scene: 'login' })
- ElMessage.success('验证码已发送(开发环境见服务端日志)')
- // 开发环境后端走 mock 通道:尝试从响应头拿不到就提示看日志
- mockCode.value = ''
- countdown.value = 60
- cdTimer = window.setInterval(() => {
- countdown.value--
- if (countdown.value <= 0) clearInterval(cdTimer)
- }, 1000)
-}
-
-async function doCodeLogin() {
- if (!codeForm.phone_number || !codeForm.code) return ElMessage.warning('请输入手机号和验证码')
- loading.value = true
- try {
- const d = await api<{ token: string; user: { nickname: string } }>('post', '/api/code-login', codeForm)
- auth.setSession(d.token, d.user?.nickname ?? '')
- router.push('/dashboard')
- } finally { loading.value = false }
-}
async function doLogin() {
if (!loginForm.phone_number || !loginForm.password) return ElMessage.warning('请输入手机号和密码')
@@ -111,6 +77,7 @@ async function doRegister() {
try {
const d = await api<{ token: string; user_id: number }>('post', '/api/auth/register', {
phone_number: regForm.phone_number, password: regForm.password,
+ invite_code: regForm.invite_code,
})
auth.setSession(d.token, regForm.phone_number.slice(-4))
ElMessage.success('注册成功')
@@ -123,9 +90,14 @@ async function doRegister() {
diff --git a/frontend/src/views/MarketView.vue b/frontend/src/views/MarketView.vue
index ce5b138..a2e3ef5 100644
--- a/frontend/src/views/MarketView.vue
+++ b/frontend/src/views/MarketView.vue
@@ -4,34 +4,29 @@
{{ m.name }}
- {{ m.enabled ? '已开通' : '未开通' }}
+ 已包含
{{ m.desc }}
-
+
diff --git a/frontend/src/views/QuotationsView.vue b/frontend/src/views/QuotationsView.vue
index f35fef2..c750524 100644
--- a/frontend/src/views/QuotationsView.vue
+++ b/frontend/src/views/QuotationsView.vue
@@ -176,9 +176,11 @@ async function detail(row: any) {
async function toOrder(row: any) {
await ElMessageBox.confirm(`将报价单 ${row.quote_no} 转为订单?`, '提示')
+ const q = await api('get', `/api/quotations/${row.id}`)
await api('post', '/api/orders', { customer_id: row.customer_id, money: row.total,
- drawing_data: '', note: `来自报价单${row.quote_no}` })
- ElMessage.success('订单已创建')
+ drawing_data: q.drawing_data || '',
+ note: `来自报价单${row.quote_no}` })
+ ElMessage.success('订单已创建(含图纸)')
router.push('/orders')
}
diff --git a/src/huamenchuang/api/auth.py b/src/huamenchuang/api/auth.py
index 40ac55c..77c188d 100644
--- a/src/huamenchuang/api/auth.py
+++ b/src/huamenchuang/api/auth.py
@@ -28,14 +28,27 @@ async def login(body: LoginRequest, db: DbSession):
}, message="登录成功")
+class RegisterBody(BaseModel):
+ phone_number: str
+ password: str
+ invite_code: str = ""
+
+
@router.post("/register")
-async def register(body: LoginRequest, db: DbSession):
- """注册并直接返回 token。"""
+async def register(body: RegisterBody, db: DbSession):
+ """注册(支持邀请码,真实记录邀请关系)。"""
service = AuthService(db)
try:
user = await service.register(body.phone_number, body.password)
except ValueError as e:
return fail(str(e))
+ if body.invite_code.startswith("HMC"):
+ try:
+ inviter_id = int(body.invite_code[3:])
+ if inviter_id != user.id:
+ user.invited_by = inviter_id
+ except ValueError:
+ pass
from huamenchuang.core.security import create_token
return ok(data={"token": create_token(user.id), "user_id": user.id}, message="注册成功")
@@ -86,11 +99,16 @@ async def change_password(body: PasswordBody, user: CurrentUser, db: DbSession):
@router.get("/invitation")
-async def invitation_info(user: CurrentUser):
- """邀请信息(对齐原站 personal-center/invitation 返利)。"""
+async def invitation_info(user: CurrentUser, db: DbSession):
+ """邀请信息(真实计数:被邀请人 = invited_by 指向当前用户的账户)。"""
+ from sqlalchemy import func, select
+ from huamenchuang.models.user import User
+ invited = (await db.execute(
+ select(func.count()).select_from(User).where(User.invited_by == user.id)
+ )).scalar_one()
return ok(data={
"invite_code": f"HMC{user.id:06d}",
- "invited_count": 0,
- "bounty": "0.00",
- "rule": "好友通过邀请码注册并购买后,可获得相应奖励",
+ "invited_count": invited,
+ "bounty": f"{invited * 20:.2f}",
+ "rule": "好友通过邀请码注册后,每位奖励 ¥20(购买后可抵扣)",
})
diff --git a/src/huamenchuang/api/platform.py b/src/huamenchuang/api/platform.py
index 600042b..ca58879 100644
--- a/src/huamenchuang/api/platform.py
+++ b/src/huamenchuang/api/platform.py
@@ -1,133 +1,47 @@
-"""平台扩展路由:验证码登录、支付、渲染设置、下料方案、设备、分享、文章。"""
+"""平台扩展路由:渲染设置、下料方案、设备、分享、文章、AI 炫图风格配置。"""
import datetime
-import random
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter
from pydantic import BaseModel, Field
-from sqlalchemy import desc, func, select
+from sqlalchemy import desc, 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 Quotation
+from huamenchuang.core.response import fail, ok
from huamenchuang.models.contract import Contract
-from huamenchuang.models.extend import Article, CompanyParam, Device, PayOrder, SmsCode
+from huamenchuang.models.extend import Article, CompanyParam, Device
from huamenchuang.models.order import Order
-from huamenchuang.services.quote_service import quote as calc_quote
router = APIRouter(tags=["平台扩展"])
-# ---------------- 短信验证码(provider 适配层) ----------------
-async def _issue_code(db, phone: str, scene: str) -> str:
- """签发验证码。未接入短信签名时走 mock 通道(控制台输出,响应不回传码)。"""
- code = f"{random.randint(0, 999999):06d}"
- db.add(SmsCode(phone_number=phone, code=code, scene=scene))
- print(f"[SMS:mock] {phone} 的{scene}验证码: {code}") # 接入真实短信后替换为服务商调用
- return code
-
-
-class CodeBody(BaseModel):
- phone_number: str
- scene: str = Field(default="login", pattern="^(login|reset|bind)$")
-
-
-@router.post("/verification-code-new")
-async def send_code(body: CodeBody, db: DbSession):
- """发送短信验证码(mock 通道,见服务端日志)。"""
- await _issue_code(db, body.phone_number, body.scene)
- return ok(message="验证码已发送")
-
-
-class CodeLoginBody(BaseModel):
- phone_number: str
- code: str
-
-
-@router.post("/code-login")
-async def code_login(body: CodeLoginBody, db: DbSession):
- """验证码登录:校验验证码后直接登录/注册。"""
- stmt = (select(SmsCode).where(SmsCode.phone_number == body.phone_number, SmsCode.used == 0,
- SmsCode.scene == "login").order_by(desc(SmsCode.id)).limit(1))
- record = (await db.execute(stmt)).scalar_one_or_none()
- if record is None or record.code != body.code:
- return fail("验证码错误或已过期")
- record.used = 1
- from huamenchuang.core.security import create_token
- from huamenchuang.models.user import User
- from huamenchuang.services.auth_service import hash_password
- u = (await db.execute(select(User).where(User.phone_number == body.phone_number))).scalar_one_or_none()
- if u is None:
- u = User(phone_number=body.phone_number, password_hash=hash_password(body.code),
- nickname=f"用户{body.phone_number[-4:]}")
- db.add(u)
- await db.flush()
- return ok(data={"token": create_token(u.id), "user": {"id": u.id, "nickname": u.nickname}},
- message="登录成功")
-
-
-# ---------------- 支付订单(通道适配层) ----------------
-
-class PayBody(BaseModel):
- subject: str
- amount: float = Field(gt=0)
- channel: str = Field(default="wechat", pattern="^(wechat|alipay)$")
-
-
-@router.post("/pay-orders")
-async def create_pay_order(body: PayBody, user: CurrentUser, db: DbSession):
- """创建支付订单。微信/支付宝商户凭据配置后接通真实下单;当前返回待支付单。"""
- today = datetime.date.today().strftime("%Y%m%d")
- seq = random.randint(0, 9999)
- po = PayOrder(company_id=user.company_id, user_id=user.id, subject=body.subject[:128],
- amount=body.amount, channel=body.channel, pay_no=f"PAY{today}{seq:04d}")
- db.add(po)
- await db.flush()
- return ok(data={
- "pay_no": po.pay_no, "amount": float(po.amount), "channel": po.channel,
- "status": po.status,
- "pay_url": "", # 微信/支付宝下单接口返回的支付链接,凭据就绪后填充
- "tip": "支付通道未配置商户凭据,下单已记录,接通后自动唤起收银台",
- }, message="支付订单已创建")
-
-
-@router.get("/pay-orders")
-async def list_pay_orders(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15):
- conds = [PayOrder.company_id == user.company_id]
- total = (await db.execute(select(func.count()).select_from(PayOrder).where(*conds))).scalar_one()
- stmt = (select(PayOrder).where(*conds).order_by(desc(PayOrder.id))
- .offset((page - 1) * page_size).limit(page_size))
- items = list((await db.execute(stmt)).scalars())
- return ok(data=paginated([{
- "pay_no": p.pay_no, "subject": p.subject, "amount": float(p.amount),
- "channel": p.channel, "status": p.status, "created_at": str(p.created_at),
- } for p in items], page, page_size, total))
-
-
-# ---------------- 渲染设置 + 简易渲染参数 ----------------
+# ---------------- 渲染设置 + AI 炫图风格配置 ----------------
class RenderSettingBody(BaseModel):
wall_color: str = "#f5f0e8"
scene: str = Field(default="indoor", pattern="^(indoor|outdoor|studio)$")
glass_tint: str = Field(default="#cfe3ee", pattern="^#[0-9a-fA-F]{6}$")
+ ai_style: str = Field(default="modern", pattern="^(modern|chinese|european|nordic)$")
+ ai_time: str = Field(default="day", pattern="^(day|dusk|night)$")
+
+
+async def _get_param(db, company_id: int, key: str):
+ stmt = select(CompanyParam).where(CompanyParam.company_id == company_id, CompanyParam.key == key)
+ return (await db.execute(stmt)).scalar_one_or_none()
@router.get("/rendering-info")
async def get_render_setting(user: CurrentUser, db: DbSession):
- stmt = select(CompanyParam).where(CompanyParam.company_id == user.company_id,
- CompanyParam.key == "render_setting")
- p = (await db.execute(stmt)).scalar_one_or_none()
import json as _json
- return ok(data=_json.loads(p.value) if p else {
- "wall_color": "#f5f0e8", "scene": "indoor", "glass_tint": "#cfe3ee"})
+ p = await _get_param(db, user.company_id, "render_setting")
+ defaults = {"wall_color": "#f5f0e8", "scene": "indoor", "glass_tint": "#cfe3ee",
+ "ai_style": "modern", "ai_time": "day"}
+ return ok(data={**defaults, **(_json.loads(p.value) if p else {})})
@router.post("/rendering-info")
async def save_render_setting(body: RenderSettingBody, user: CurrentUser, db: DbSession):
import json as _json
- stmt = select(CompanyParam).where(CompanyParam.company_id == user.company_id,
- CompanyParam.key == "render_setting")
- p = (await db.execute(stmt)).scalar_one_or_none()
+ p = await _get_param(db, user.company_id, "render_setting")
if p is None:
p = CompanyParam(company_id=user.company_id, key="render_setting")
db.add(p)
@@ -176,6 +90,16 @@ async def cut_solution(body: CutSolutionBody, user: CurrentUser, db: DbSession):
message="下料方案已生成")
+@router.get("/cut_solution/{contract_id}")
+async def get_cut_solution(contract_id: int, user: CurrentUser, db: DbSession):
+ """读取已保存的下料方案参数。"""
+ p = await _get_param(db, user.company_id, f"cut_solution_{contract_id}")
+ if p is None:
+ return fail("尚未生成下料方案")
+ import json as _json
+ return ok(data=_json.loads(p.value))
+
+
async def _save_param(db, company_id: int, key: str, value: str) -> None:
stmt = select(CompanyParam).where(CompanyParam.company_id == company_id, CompanyParam.key == key)
p = (await db.execute(stmt)).scalar_one_or_none()
@@ -239,19 +163,16 @@ def make_share_token(order_id: int) -> str:
def parse_share_token(token: str) -> int | None:
- import base64
+ import base64, hmac as _hmac
try:
raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)).decode()
oid_s, sig = raw.split(".", 1)
oid = int(oid_s)
- return oid if hmac.compare_digest(sig, _sign(oid)) else None
+ return oid if _hmac.compare_digest(sig, _sign(oid)) else None
except Exception:
return None
-import hmac # noqa: E402
-
-
@router.get("/shared-order/{token}")
async def shared_order(token: str, db: DbSession):
"""通过分享 token 公开只读订单摘要(无需登录)。"""
diff --git a/src/huamenchuang/api/quote.py b/src/huamenchuang/api/quote.py
index 675e98f..73f9764 100644
--- a/src/huamenchuang/api/quote.py
+++ b/src/huamenchuang/api/quote.py
@@ -15,6 +15,7 @@ class QuoteRequest(BaseModel):
template_id: int | None = Field(default=None, description="或直接指定样板")
order_id: int | None = Field(default=None, description="或对已有订单报价")
prices: dict | None = Field(default=None, description="价格本覆盖 {frame,mullion,sash,glass,hardware}")
+ use_price_book: bool = Field(default=False, description="从产品管理价格本取价(按材料类别均价)")
async def _resolve_drawing(db, body: QuoteRequest) -> str | dict | None:
@@ -32,14 +33,38 @@ async def _resolve_drawing(db, body: QuoteRequest) -> str | dict | None:
return None
+async def _price_book(db, company_id: int) -> dict | None:
+ """从产品价格本推导报价参数:各类材料均价。"""
+ from sqlalchemy import func, select
+ from huamenchuang.models.business import Product
+ rows = (await db.execute(
+ select(Product.material, func.avg(Product.price))
+ .where(Product.company_id == company_id, Product.enabled == 1)
+ .group_by(Product.material))).all()
+ if not rows:
+ return None
+ avg = {m: float(p or 0) for m, p in rows}
+ prices: dict = {}
+ if avg.get("profile"):
+ prices["frame"] = prices["mullion"] = prices["sash"] = round(avg["profile"], 2)
+ if avg.get("glass"):
+ prices["glass"] = round(avg["glass"], 2)
+ if avg.get("hardware"):
+ prices["hardware"] = round(avg["hardware"], 2)
+ return prices or None
+
+
@router.post("")
async def offer_price(body: QuoteRequest, user: CurrentUser, db: DbSession):
"""算料报价:型材清单 + 玻璃面积 + 五金 + 总价。"""
drawing = await _resolve_drawing(db, body)
if drawing is None:
return fail("未找到图纸数据")
+ prices = body.prices
+ if prices is None and body.use_price_book:
+ prices = await _price_book(db, user.company_id)
try:
- result = calc_quote(drawing, body.prices)
+ result = calc_quote(drawing, prices)
except Exception:
return fail("图纸数据解析失败")
return ok(data=result, message="报价成功")
diff --git a/src/huamenchuang/models/user.py b/src/huamenchuang/models/user.py
index fb4aef4..0f041f6 100644
--- a/src/huamenchuang/models/user.py
+++ b/src/huamenchuang/models/user.py
@@ -19,6 +19,7 @@ class User(Base):
language: Mapped[str] = mapped_column(String(16), default="zh-CN")
# 套餐到期时间,空=试用
try_expiration_time: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
+ invited_by: Mapped[int] = mapped_column(default=0, index=True) # 邀请人用户ID
note: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
updated_at: Mapped[datetime.datetime] = mapped_column(