From a20f3df854e16cb6f242b8ec2d50d5bfee563617 Mon Sep 17 00:00:00 2001 From: HHY Date: Sun, 20 Sep 2026 13:15:31 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20v1.0=20AI=E7=82=AB=E5=9B=BE=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E4=B8=8A=E7=BA=BF+=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E5=81=9A=E5=AE=9E+=E6=8C=89=E9=9C=80=E8=A3=81=E5=89=AA+?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E7=B2=BE=E8=87=B4=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI炫图: 自研程序化场景合成引擎(风格×时间×场景, 家具剪影/光束/暗角, seed构图微差), 阶段化生成动画+四方案画廊+PNG下载, 引擎抽为共享模块 - 业务做实: 报价接入产品价格本(use_price_book), 注册邀请码+返利真实计数, 报价单转订单携带图纸 - 按需裁剪: 下线短信验证码登录/支付收银, 商城改为功能总览 - 主题精致化: 品牌色设计变量+Element Plus主题覆盖+视觉升级 - 全接口回归通过, 移除项405确认 --- CHANGELOG | 1 + doc/API.md | 30 ++- frontend/src/App.vue | 38 +++- frontend/src/components/AIRenderCanvas.vue | 32 ++++ frontend/src/components/variantRenderer.ts | 210 +++++++++++++++++++++ frontend/src/router/index.ts | 1 + frontend/src/views/AIRenderView.vue | 147 +++++++++++++++ frontend/src/views/LayoutView.vue | 1 + frontend/src/views/LoginView.vue | 84 +++------ frontend/src/views/MarketView.vue | 31 +-- frontend/src/views/QuotationsView.vue | 6 +- src/huamenchuang/api/auth.py | 32 +++- src/huamenchuang/api/platform.py | 139 +++----------- src/huamenchuang/api/quote.py | 27 ++- src/huamenchuang/models/user.py | 1 + 15 files changed, 566 insertions(+), 214 deletions(-) create mode 100644 frontend/src/components/AIRenderCanvas.vue create mode 100644 frontend/src/components/variantRenderer.ts create mode 100644 frontend/src/views/AIRenderView.vue diff --git a/CHANGELOG b/CHANGELOG index 3d986c2..b980844 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) diff --git a/doc/API.md b/doc/API.md index 7e84c08..79ea43e 100644 --- a/doc/API.md +++ b/doc/API.md @@ -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 种子数据 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 99c5483..9ada9fb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,6 +3,42 @@ diff --git a/frontend/src/components/AIRenderCanvas.vue b/frontend/src/components/AIRenderCanvas.vue new file mode 100644 index 0000000..0e2f085 --- /dev/null +++ b/frontend/src/components/AIRenderCanvas.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/frontend/src/components/variantRenderer.ts b/frontend/src/components/variantRenderer.ts new file mode 100644 index 0000000..76f79a9 --- /dev/null +++ b/frontend/src/components/variantRenderer.ts @@ -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 = { + 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> = { + 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 @@ + + + + + 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 }}

-
- {{ m.price > 0 ? `¥${m.price}/年` : '免费' }} - {{ m.enabled ? '已开通' : '立即购买' }} -
+ 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(