feat: v1.0 AI炫图工作台上线+业务做实+按需裁剪+主题精致化

- AI炫图: 自研程序化场景合成引擎(风格×时间×场景, 家具剪影/光束/暗角,
  seed构图微差), 阶段化生成动画+四方案画廊+PNG下载, 引擎抽为共享模块
- 业务做实: 报价接入产品价格本(use_price_book), 注册邀请码+返利真实计数,
  报价单转订单携带图纸
- 按需裁剪: 下线短信验证码登录/支付收银, 商城改为功能总览
- 主题精致化: 品牌色设计变量+Element Plus主题覆盖+视觉升级
- 全接口回归通过, 移除项405确认
This commit is contained in:
HHY
2026-09-20 13:15:31 -07:00
parent 453decf2e4
commit a20f3df854
15 changed files with 566 additions and 214 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG
- [2026-09-20 22:30] FEAT: v1.0 AI炫图上线+业务做实+按需裁剪+主题精致化。①AI炫图工作台(/ai_render): 自研程序化场景合成引擎(风格×时间×场景, 家具/绿植/光束/暗角, seed构图微差), 阶段化生成动画+四方案画廊+PNG下载, 渲染引擎抽为共享模块 variantRenderer; ②业务做实: 报价接入产品价格本(use_price_book按材料类别均价), 注册支持邀请码且邀请返利真实计数(User.invited_by), 报价单转订单携带图纸; ③按需裁剪: 下线短信验证码登录/支付收银接口与相关界面, 商城改为功能总览; ④主题精致化: 品牌色设计变量+Element Plus主题覆盖+卡片/登录页/滚动条视觉升级; 全接口回归通过(移除项405确认) (Files: src/huamenchuang/api/platform.py, src/huamenchuang/api/quote.py, src/huamenchuang/api/auth.py, src/huamenchuang/models/user.py, frontend/src/components/AIRenderCanvas.vue, frontend/src/components/variantRenderer.ts, frontend/src/views/AIRenderView.vue, frontend/src/views/LoginView.vue, frontend/src/views/MarketView.vue, frontend/src/views/QuotationsView.vue, frontend/src/App.vue, doc/API.md)
- [2026-09-20 21:00] FIX+FEAT: v0.9 样板数据完整性验证与解析加固——①双源确认样板库完整性: search接口254条 = 画图编辑器for_ht目录内嵌254条, 0遗漏; ②全量体检发现6套样板含7个畸形段(缺ps/pe)导致解析报错, 前后端重放/算料均加固过滤(0异常/1046窗格); ③新增报表统计模块(/chart: 近N天订单数金额按日+8项总量+状态分布, 自绘SVG柱图); ④样板库官方/我的样板双Tab(is_default过滤); ⑤使用指南接入帮助文章接口(种子8篇自撰文章); ⑥seed脚本分类树幂等修复; 全接口回归18/18通过 (Files: src/huamenchuang/core/drawing.py, src/huamenchuang/api/reports.py, src/huamenchuang/api/windoor.py, src/huamenchuang/scripts/*, frontend/src/types/drawing.ts, frontend/src/views/ReportView.vue, frontend/src/views/TemplatesView.vue, frontend/src/views/GuideView.vue)
- [2026-09-20 20:10] FEAT: v0.8 平台扩展——3D展示/效果图渲染/打印中心/下料方案/设备模块/订单分享/验证码登录。①Three.js 3D窗型(重放区域挤出玻璃+型材盒体, 自研轨道控制);②Canvas场景合成效果图(墙面/玻璃渐变/投影, 三场景, 设置持久化到公司参数);③A4打印中心(订单模板+进度+签字栏, 打印CSS隔离);④合同级下料方案(汇总订单算料→FFD→参数持久化, 修正利用率为已排入/已用料);⑤设备激活绑定(序列号+激活码);⑥订单分享(HMAC可逆token+公开只读页);⑦mock短信验证码登录全流程可用(接签名后换provider);⑧帮助文章接口。新增12组API, 前端+6页面共18页 (Files: src/huamenchuang/api/platform.py, src/huamenchuang/models/extend.py, frontend/src/components/WindoorThreed.vue, frontend/src/components/RenderCanvas.vue, frontend/src/views/*, doc/API.md)
- [2026-09-20 19:00] FEAT: v0.7 全功能复刻与前端适配——新增产品价格本/报价单(BJ单号,手动报价+样板算料)/售后单(问题描述必填对齐原站)/量尺单(洞口尺寸清单)/阳光房订单(order_type=1)/个人中心(资料/密码/邀请返利 HMC邀请码) 模块;前端 15 页面(登录注册双Tab/商城/使用指南等)+响应式侧边栏(<900px折叠)+图标全局注册;订单新增order_type字段(含SQLite手工迁移) (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/business.py, src/huamenchuang/api/auth.py, frontend/src/views/*, frontend/src/router/index.ts, doc/API.md)
+14 -16
View File
@@ -332,28 +332,26 @@
**响应式适配**:侧边栏 <900px 自动折叠为图标栏;<700px 主区边距收窄;表格列横向滚动。
### 12.14 平台扩展模块(v0.8,`/api`)
### 12.14 平台扩展模块(v1.0,`/api`)
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---|
| 发送验证码 | POST | `/api/verification-code-new` | `{phone_number, scene: login/reset/bind}` | **mock 短信通道**(验证码打服务端日志);接入短信签名后替换 provider 即可 |
| 验证码登录 | POST | `/api/code-login` | `{phone_number, code}` | 校验通过自动注册并登录 |
| 创建支付订单 | POST | `/api/pay-orders` | `{subject, amount, channel: wechat/alipay}` | 支付通道适配层:商户凭据就绪后接通收银台,当前记录待支付单 |
| 支付单列表 | GET | `/api/pay-orders` | 分页 | |
| 渲染设置 | GET/POST | `/api/rendering-info` | `{wall_color, scene, glass_tint}` | 存公司参数(对齐原站 company_param_value) |
| **合同下料方案** | POST | `/api/cut_solution` | `{contract_id, kerf}` | 汇总合同下订单图纸的算料需求 → FFD 优化 → 参数持久化 |
| 设备激活 | POST | `/api/device-activate` | `{sn, activate_code, name}` | 首绑自动生成激活码,防重复绑定(对齐 device-activate) |
| 渲染设置 + AI 风格 | GET/POST | `/api/rendering-info` | `{wall_color, scene, glass_tint, ai_style, ai_time}` | 存公司参数;风格: modern/chinese/european/nordic,时间: day/dusk/night |
| **合同下料方案** | POST | `/api/cut_solution` | `{contract_id, kerf}` | 汇总合同下订单图纸的算料需求 → FFD 优化 → 参数持久化;`GET /api/cut_solution/{id}` 读回 |
| 设备激活 | POST | `/api/device-activate` | `{sn, activate_code, name}` | 首绑自动生成激活码,防重复绑定 |
| 设备列表 | GET | `/api/devices` | — | |
| 生成订单分享 | POST | `/api/share-order/{id}` | — | HMAC 可逆 token |
| **公开订单摘要** | GET | `/api/shared-order/{token}` | 无需登录 | 分享页数据(对齐 customOrder-share) |
| 帮助文章 | GET | `/api/articles?category=` | query | 对齐原站 articleList |
| **公开订单摘要** | GET | `/api/shared-order/{token}` | 无需登录 | 分享页数据 |
| 帮助文章 | GET | `/api/articles?category=` | query | 种子 8 篇自撰指南 |
| 报表汇总 | GET | `/api/reports/summary?days=14` | days | 按日订单数/金额 + 8 项总量 + 状态分布 |
新增前端能力(v0.8):
- **效果图渲染**(`/rendering`):自研 Canvas 场景合成(墙面色/玻璃渐变高光/投影,室内外影棚三场景),设置持久化
- **3D 展示**(`/threed`):自研 Three.js 实现——分割重放区域挤出为玻璃板 + 型材盒体 + 地面网格,自实现轨道控制(旋转/缩放/平移)
- **打印中心**(`/printer`):A4 生产订单模板(图纸缩略图+进度+签字栏),打印 CSS 隔离
- **下料方案**(`/cut_solution`):合同级汇总下料
- **设备管理**(`/devices`)、**订单分享**(`/share/order/:token` 公开页)、登录页**验证码登录** Tab(60s 倒计时)
> v1.0 调整:按需求**移除短信验证码登录与支付收银接口**(verification-code-new / code-login / pay-orders 已下线);登录采用手机号+密码。
新增前端能力(v1.0):
- **AI 炫图**(`/ai_render`):自研程序化场景合成引擎——风格(现代简约/新中式/欧式/北欧)× 时间(日景/黄昏/夜景)× 场景(室内/室外/影棚),阶段化生成动画,四方案画廊,一键下载 PNG
- **效果图渲染**(`/rendering`)与 **3D 展示**(`/threed`,Three.js 挤出重建)
- **打印中心**(`/printer`)、**下料方案**(`/cut_solution`)、**设备管理**(`/devices`)、**订单分享**(`/share/order/:token` 公开页)、**报表统计**(`/chart`)
- **主题精致化**:品牌色设计变量、Element Plus 主题覆盖、卡片圆角阴影、登录页氛围渐变、响应式侧边栏
### 12.13 种子数据
+37 -1
View File
@@ -3,6 +3,42 @@
</template>
<style>
/* ===== 全局设计变量 ===== */
:root {
--brand: #3f5f80;
--brand-2: #6b8cae;
--accent: #5b3fd4;
--danger: #e6483d;
--radius: 10px;
--shadow-card: 0 2px 12px rgba(30, 45, 65, 0.08);
}
/* Element Plus 主题覆盖 */
:root {
--el-color-primary: #3f5f80;
--el-color-primary-light-3: #66819f;
--el-color-primary-light-5: #8fa3ba;
--el-color-primary-light-7: #b9c6d4;
--el-color-primary-light-8: #ccd6e0;
--el-color-primary-light-9: #e5ebf1;
--el-color-primary-dark-2: #324c66;
--el-border-radius-base: 8px;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif; }
body {
margin: 0;
font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
}
/* 卡片统一圆角与悬停 */
.el-card { border-radius: var(--radius); border: none; box-shadow: var(--shadow-card) !important; }
.el-button { border-radius: 8px; }
/* 表头底色 */
.el-table th.el-table__cell { background: #f7f9fb; }
/* 页面切换动效 */
.fade-enter-active, .fade-leave-active { transition: opacity .18s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* 滚动条 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #c9d2db; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #a9b6c2; }
</style>
@@ -0,0 +1,32 @@
<template>
<canvas ref="cvRef" class="ai-canvas" />
</template>
<script setup lang="ts">
/** AI 炫图画布组件:薄封装,渲染引擎见 variantRenderer.ts */
import { onMounted, ref, watch } from 'vue'
import { renderAI } from './variantRenderer'
const props = withDefaults(defineProps<{
drawingData: string | object
style?: 'modern' | 'chinese' | 'european' | 'nordic'
time?: 'day' | 'dusk' | 'night'
scene?: 'indoor' | 'outdoor' | 'studio'
seed?: number
}>(), { style: 'modern', time: 'day', scene: 'indoor', seed: 1 })
const cvRef = ref<HTMLCanvasElement>()
function render() {
if (cvRef.value && props.drawingData) renderAI(cvRef.value, props.drawingData, props.style, props.time, props.scene, props.seed)
}
onMounted(() => {
watch(() => [props.drawingData, props.style, props.time, props.scene, props.seed], render, { immediate: true })
window.addEventListener('resize', render)
})
</script>
<style scoped>
.ai-canvas { width: 100%; height: 100%; display: block; border-radius: 8px; }
</style>
+210
View File
@@ -0,0 +1,210 @@
/**
* AI 炫图渲染引擎(自研程序化场景合成,纯函数)
* renderAI(canvas, drawingData, style, time, scene, seed)
* 墙面/地板/家具剪影/天空/玻璃光影/光束/暗角;seed 决定构图微差。
*/
import { bboxOf, parseDrawing, regionPoints, replayShape, type DrawingData } from '../types/drawing'
export interface Palette {
wall: [string, string]; floor: [string, string]; sky: [string, string, string]
frame: string; accent: string; light: string
}
export const PRESETS: Record<string, Palette> = {
modern: { wall: ['#f6f2ea', '#e7e0d4'], floor: ['#c9a97a', '#a98a5f'], sky: ['#bcd9f0', '#e8f2fa', '#f8fbff'], frame: '#3a3e43', accent: '#7a8b6f', light: '#fff7e0' },
chinese: { wall: ['#f3e9d7', '#e5d5ba'], floor: ['#8a4a32', '#6d3826'], sky: ['#c2d8ea', '#e6eef5', '#f5f8fb'], frame: '#7a3b28', accent: '#9a6b4f', light: '#ffedc9' },
european: { wall: ['#f7f1e3', '#ece0c8'], floor: ['#b08d5f', '#8f6f4a'], sky: ['#aecdea', '#dbe8f5', '#f2f7fd'], frame: '#e8e2d2', accent: '#c9b28a', light: '#fff3d6' },
nordic: { wall: ['#f2f3f1', '#e2e5e1'], floor: ['#d9c6a5', '#bfa87f'], sky: ['#c5dcf2', '#e9f4fc', '#fafdff'], frame: '#f4f2ec', accent: '#8fae9c', light: '#fdf9ec' },
}
const TIME_SKY: Record<string, Record<string, [string, string, string]>> = {
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)
}
+1
View File
@@ -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: '下料方案' } },
+147
View File
@@ -0,0 +1,147 @@
<template>
<el-card>
<template #header>
<div class="head">
<span class="brand">✦ AI 炫图</span>
<span class="sub">选择样板 → 设定风格 → 一键生成多方案效果图</span>
</div>
</template>
<div class="controls">
<el-select v-model="templateId" filterable placeholder="选择样板" style="width:220px" @change="loadTemplate">
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
</el-select>
<el-select v-model="form.ai_style" style="width:130px">
<el-option value="modern" label="现代简约" /><el-option value="chinese" label="新中式" />
<el-option value="european" label="欧式" /><el-option value="nordic" label="北欧" />
</el-select>
<el-select v-model="form.ai_time" style="width:110px">
<el-option value="day" label="日景" /><el-option value="dusk" label="黄昏" /><el-option value="night" label="夜景" />
</el-select>
<el-select v-model="form.scene" style="width:110px">
<el-option value="indoor" label="室内" /><el-option value="outdoor" label="室外" /><el-option value="studio" label="影棚" />
</el-select>
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generate">
{{ generating ? '生成中…' : '✦ 生成 AI 炫图' }}</el-button>
</div>
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
:format="() => stageText" />
<div v-if="drawing && !variants.length && !generating" class="preview">
<AIRenderCanvas :drawing-data="drawing" :style="form.ai_style" :time="form.ai_time"
:scene="form.scene" :seed="form.seed" />
</div>
<div v-if="variants.length" class="gallery">
<div v-for="(v, i) in variants" :key="i" class="shot" :class="{ pick: pickIdx === i }" @click="pickIdx = i">
<canvas :ref="el => setVariantRef(el, i)" class="shot-cv" />
<div class="cap">{{ v.label }}</div>
<el-icon class="dl" @click.stop="download(i)"><Download /></el-icon>
</div>
</div>
<el-empty v-if="!drawing" description="请先选择样板" />
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import AIRenderCanvas from '../components/AIRenderCanvas.vue'
const templates = ref<any[]>([])
const templateId = ref<number | undefined>()
const drawing = ref('')
const generating = ref(false)
const progress = ref(0)
const stageText = ref('')
const variants = ref<{ label: string; style: string; time: string; seed: number }[]>([])
const pickIdx = ref(0)
const form = reactive({ ai_style: 'modern', ai_time: 'day', scene: 'indoor', seed: 1 })
const variantCvs: (HTMLCanvasElement | null)[] = []
function setVariantRef(el: any, i: number) { variantCvs[i] = el as HTMLCanvasElement }
const STAGES = ['解析窗型结构', '合成场景光照', '匹配材质纹理', '生成效果图']
async function loadTemplate(tid: number | undefined) {
drawing.value = ''
variants.value = []
if (!tid) return
const t = await api<any>('get', `/api/windoor/templates/${tid}`)
drawing.value = t.drawing_data
}
async function generate() {
generating.value = true
variants.value = []
// 阶段化进度(本地程序化渲染,进度为演示动画)
for (let s = 0; s < STAGES.length; s++) {
stageText.value = STAGES[s]
for (let p = 0; p <= 25; p += 5) {
progress.value = Math.min(100, s * 25 + p)
await new Promise(r => setTimeout(r, 24))
}
}
progress.value = 100
// 生成 4 个方案:风格×时间组合 + seed 微差
variants.value = [
{ label: `${styleName(form.ai_style)} · ${timeName(form.ai_time)}`, style: form.ai_style, time: form.ai_time, seed: form.seed },
{ label: `${styleName(form.ai_style)} · 日景`, style: form.ai_style, time: 'day', seed: form.seed + 11 },
{ label: `${styleName(form.ai_style)} · 黄昏`, style: form.ai_style, time: 'dusk', seed: form.seed + 23 },
{ label: '新中式 · 夜景', style: 'chinese', time: 'night', seed: form.seed + 37 },
]
pickIdx.value = 0
generating.value = false
await nextFrames()
// 渲染画廊各画布(共享渲染引擎)
const { renderAI } = await import('../components/variantRenderer')
variants.value.forEach((v, i) => {
const cv = variantCvs[i]
if (cv) renderAI(cv, drawing.value, v.style, v.time, form.scene, v.seed)
})
ElMessage.success('已生成 4 套效果图')
}
function nextFrames(): Promise<void> {
return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r())))
}
function styleName(s: string) { return ({ modern: '现代简约', chinese: '新中式', european: '欧式', nordic: '北欧' } as Record<string, string>)[s] ?? s }
function timeName(t: string) { return ({ day: '日景', dusk: '黄昏', night: '夜景' } as Record<string, string>)[t] ?? t }
function download(i: number) {
const cv = variantCvs[i]
if (!cv) return
const a = document.createElement('a')
a.href = cv.toDataURL('image/png')
a.download = `AI炫图_${i + 1}.png`
a.click()
}
onMounted(async () => {
templates.value = await api<any>('get', '/api/windoor/templates?page=1&page_size=254')
Object.assign(form, { ai_style: form.ai_style, ai_time: form.ai_time, scene: form.scene },
await api<any>('get', '/api/rendering-info'))
})
</script>
<style scoped>
.head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.brand { font-size: 17px; font-weight: 700; color: #5b3fd4; }
.sub { color: #909399; font-size: 12px; }
.controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.preview { height: 58vh; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.shot { position: relative; border: 2px solid transparent; border-radius: 10px; overflow: hidden; cursor: pointer;
height: 200px; transition: border-color .2s, transform .2s; }
.shot:hover { transform: translateY(-2px); }
.shot.pick { border-color: #5b3fd4; }
.shot-cv { width: 100%; height: 100%; display: block; }
.cap { position: absolute; left: 8px; bottom: 6px; color: #fff; font-size: 12px;
background: rgba(0,0,0,0.45); padding: 2px 8px; border-radius: 10px; }
.dl { position: absolute; right: 8px; top: 8px; color: #fff; background: rgba(0,0,0,0.45);
border-radius: 6px; padding: 4px; cursor: pointer; }
</style>
+1
View File
@@ -19,6 +19,7 @@
<el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item>
<el-menu-item index="/cut_solution"><el-icon><SetUp /></el-icon>下料方案</el-menu-item>
<el-menu-item index="/rendering"><el-icon><Picture /></el-icon>效果图渲染</el-menu-item>
<el-menu-item index="/ai_render"><el-icon><MagicStick /></el-icon>AI 炫图</el-menu-item>
<el-menu-item index="/threed"><el-icon><View /></el-icon>3D 展示</el-menu-item>
<el-menu-item index="/printer"><el-icon><Printer /></el-icon>打印中心</el-menu-item>
<el-menu-item index="/devices"><el-icon><Cpu /></el-icon>设备管理</el-menu-item>
+28 -56
View File
@@ -7,47 +7,40 @@
<el-tab-pane label="登录" name="login">
<el-form :model="loginForm" @keyup.enter="doLogin">
<el-form-item>
<el-input v-model="loginForm.phone_number" placeholder="手机号" size="large" />
<el-input v-model="loginForm.phone_number" placeholder="手机号" size="large">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item>
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password />
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password>
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">登 录</el-button>
<el-button text type="primary" style="width:100%; margin: 8px 0 0" @click="tab = 'code'">验证码登录</el-button>
</el-form>
</el-tab-pane>
<el-tab-pane label="验证码登录" name="code">
<el-form :model="codeForm" @keyup.enter="doCodeLogin">
<el-form-item>
<el-input v-model="codeForm.phone_number" placeholder="手机号" size="large" />
</el-form-item>
<el-form-item>
<div style="display:flex; gap:8px; width:100%">
<el-input v-model="codeForm.code" placeholder="验证码" size="large" style="flex:1" />
<el-button size="large" :disabled="countdown > 0" @click="sendCode">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}</el-button>
</div>
</el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doCodeLogin">登 录</el-button>
<p class="sms-tip" v-if="mockCode">开发提示:mock 验证码 {{ mockCode }}(见服务端日志)</p>
<el-button type="primary" size="large" class="w-full" :loading="loading" @click="doLogin">登 录</el-button>
</el-form>
</el-tab-pane>
<el-tab-pane label="注册" name="register">
<el-form :model="regForm" @keyup.enter="doRegister">
<el-form-item>
<el-input v-model="regForm.phone_number" placeholder="手机号" size="large" />
<el-input v-model="regForm.phone_number" placeholder="手机号" size="large">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item>
<el-input v-model="regForm.password" type="password" placeholder="设置密码(至少4位)" size="large" show-password />
<el-input v-model="regForm.password" type="password" placeholder="设置密码(至少4位)" size="large" show-password>
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item>
<el-input v-model="regForm.invite_code" placeholder="邀请码(可选)" size="large" />
<el-input v-model="regForm.invite_code" placeholder="邀请码(可选,如 HMC000001)" size="large">
<template #prefix><el-icon><Promotion /></el-icon></template>
</el-input>
</el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doRegister">注 册</el-button>
<el-button type="primary" size="large" class="w-full" :loading="loading" @click="doRegister">注 册</el-button>
</el-form>
</el-tab-pane>
</el-tabs>
<p class="tip">开发账号:13800000000 / 123456 | 忘记密码请联系管理员</p>
<p class="tip">开发账号:13800000000 / 123456</p>
</el-card>
</div>
</template>
@@ -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() {
<style scoped>
.login-page { height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #35506b 0%, #6b8cae 100%); }
.login-card { width: 380px; text-align: center; padding: 12px 8px; }
h2 { margin: 8px 0 4px; color: #303133; }
.sub { color: #909399; font-size: 13px; margin: 0 0 12px; }
background:
radial-gradient(1200px 600px at 15% 10%, rgba(107,140,174,0.55), transparent),
radial-gradient(1000px 500px at 85% 90%, rgba(91,63,212,0.35), transparent),
linear-gradient(135deg, #2b3a4d 0%, #46617f 60%, #5b74a0 100%); }
.login-card { width: 400px; padding: 16px 12px; border-radius: 14px;
box-shadow: 0 20px 60px rgba(20, 35, 55, 0.45); }
h2 { margin: 8px 0 4px; color: #303133; letter-spacing: 2px; }
.sub { color: #909399; font-size: 13px; margin: 0 0 14px; }
.tip { color: #c0c4cc; font-size: 12px; margin-top: 14px; }
.w-full { width: 100%; }
</style>
+9 -22
View File
@@ -4,34 +4,29 @@
<el-card shadow="hover" class="module">
<div class="m-head">
<span class="m-name">{{ m.name }}</span>
<el-tag size="small" :type="m.enabled ? 'success' : 'info'">{{ m.enabled ? '已开通' : '未开通' }}</el-tag>
<el-tag size="small" type="success">已包含</el-tag>
</div>
<p class="m-desc">{{ m.desc }}</p>
<div class="m-foot">
<span class="m-price">{{ m.price > 0 ? `¥${m.price}/年` : '免费' }}</span>
<el-button size="small" :type="m.enabled ? 'default' : 'primary'" :disabled="m.enabled"
@click="buy(m)">{{ m.enabled ? '已开通' : '立即购买' }}</el-button>
</div>
</el-card>
</el-col>
</el-row>
<el-alert type="info" :closable="false" show-icon style="margin-top:8px"
title="以上功能在当前版本中全部开放使用;按需开通的订阅方案已预留(HMC_ENABLED_MODULES)。" />
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
const modules = ref<any[]>([])
// 全部功能对演示账户开放(后端 HMC_ENABLED_MODULES 为准)
const MODULE_DEFS = [
{ key: 'order', name: '订单管理', desc: '订单全流程:报价下单、生产进度、图纸管理', price: 299 },
{ key: 'quote', name: '智能报价', desc: '画图自动算料报价,型材/玻璃/五金明细', price: 199 },
{ key: 'optimize', name: '优化下料', desc: '一维型材优化切割,提高利用率', price: 199 },
{ key: 'template', name: '样板库', desc: '254 套官方窗型样板 + 自定义样板', price: 0 },
{ key: 'warehouse', name: '仓库库存', desc: '材料出入库、库存预警', price: 99 },
{ key: 'render', name: '效果图渲染', desc: '照片级渲染(规划中)', price: 399 },
{ key: 'order', name: '订单管理', desc: '订单全流程:报价下单、生产进度、图纸管理、打印' },
{ key: 'quote', name: '智能报价', desc: '画图自动算料报价,型材/玻璃/五金明细,接价格本' },
{ key: 'optimize', name: '优化下料', desc: '一维型材优化切割(FFD),合同级下料方案' },
{ key: 'template', name: '样板库', desc: '254 套官方窗型样板 + 自定义样板' },
{ key: 'warehouse', name: '仓库库存', desc: '材料出入库、流水、库存预警' },
{ key: 'render', name: '渲染工作台', desc: '效果图场景合成 + AI 炫图 + 3D 展示' },
]
onMounted(async () => {
@@ -42,12 +37,6 @@ onMounted(async () => {
} catch { enabled = [] }
modules.value = MODULE_DEFS.map(m => ({ ...m, enabled: enabled.includes(m.key) }))
})
async function buy(m: any) {
await ElMessageBox.confirm(
`开通「${m.name}」需要 ¥${m.price}/年。演示环境未接入支付,可联系管理员在 HMC_ENABLED_MODULES 中开通。`,
'购买', { type: 'info' })
}
</script>
<style scoped>
@@ -55,6 +44,4 @@ async function buy(m: any) {
.m-head { display: flex; justify-content: space-between; align-items: center; }
.m-name { font-size: 16px; font-weight: 600; }
.m-desc { color: #909399; font-size: 13px; min-height: 38px; margin: 10px 0; }
.m-foot { display: flex; justify-content: space-between; align-items: center; }
.m-price { color: #e6483d; font-weight: 700; }
</style>
+4 -2
View File
@@ -176,9 +176,11 @@ async function detail(row: any) {
async function toOrder(row: any) {
await ElMessageBox.confirm(`将报价单 ${row.quote_no} 转为订单?`, '提示')
const q = await api<any>('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')
}
+25 -7
View File
@@ -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(购买后可抵扣)",
})
+30 -109
View File
@@ -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 公开只读订单摘要(无需登录)。"""
+26 -1
View File
@@ -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="报价成功")
+1
View File
@@ -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(