feat: v0.8 平台扩展——3D渲染/打印中心/下料方案/设备/分享/验证码登录

- 3D展示: Three.js挤出重建窗型(玻璃半透明+型材盒体), 自研轨道控制
- 效果图渲染: Canvas场景合成(墙面/玻璃渐变高光/投影/三场景), 设置持久化
- 打印中心: A4生产订单模板(图纸+进度+签字栏), 打印CSS隔离
- 下料方案: 合同级汇总订单算料需求→FFD优化, 修正利用率为已排入/已用料
- 设备模块: 序列号+激活码绑定, 防重复绑定
- 订单分享: HMAC可逆token + 免登录公开只读页(customOrder-share)
- 验证码登录: mock短信通道全流程可用(发码/校验/自动注册), 接签名后换provider
- 帮助文章接口; 新增支付单适配层(微信/支付宝凭据就绪即接通)
- 前端新增6页面, 共18页面
This commit is contained in:
HHY
2026-09-20 12:10:26 -07:00
parent b2730af80b
commit 3f871d7eeb
19 changed files with 1192 additions and 3 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
- [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) - [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)
- [2026-09-20 11:15] FEAT: v0.6 分割树架构——横梃竖梃完整编辑。①逆向确认原站设计思路: polygon.polygon为基底多边形+mm.sp[]为分割树(polyId指向作用区域), 运行时重放得最终窗格(254样本验证); ②前端 replayShape 分割树重放, 网格渲染真实化(平开1矩形→4-7格); ③中梃编辑: 点击选中(高亮)/拖动移动(line.pt更新→重放,30mm钳制)/删除(移除sp→重放), 加梃指向被点窗格; ④Python侧同语义replay_shape, 报价中梃长度精确到实际作用跨度(2375/2350/1642/979mm); ⑤方案文档§3.3更新原站设计思路与编辑器完成设计 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, src/huamenchuang/core/drawing.py, src/huamenchuang/services/quote_service.py, doc/克隆实施方案.md) - [2026-09-20 11:15] FEAT: v0.6 分割树架构——横梃竖梃完整编辑。①逆向确认原站设计思路: polygon.polygon为基底多边形+mm.sp[]为分割树(polyId指向作用区域), 运行时重放得最终窗格(254样本验证); ②前端 replayShape 分割树重放, 网格渲染真实化(平开1矩形→4-7格); ③中梃编辑: 点击选中(高亮)/拖动移动(line.pt更新→重放,30mm钳制)/删除(移除sp→重放), 加梃指向被点窗格; ④Python侧同语义replay_shape, 报价中梃长度精确到实际作用跨度(2375/2350/1642/979mm); ⑤方案文档§3.3更新原站设计思路与编辑器完成设计 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, src/huamenchuang/core/drawing.py, src/huamenchuang/services/quote_service.py, doc/克隆实施方案.md)
- [2026-09-20 10:39] FEAT: v0.5 画布尺寸编辑+自动报价+扇样式保真修复——①参数化尺寸编辑: W/H输入框与拖拽外框右缘/下缘/右下角手柄, 全部几何按比例自动缩放(料宽不变,最小300mm), 拖拽静默/松手提交, 撤销栈; ②自动报价: 画布变化600ms防抖自动调offer-price并实时显示总价(样板库/订单详情); ③修复"样板显示为默认样式": 扇样式推断引擎三级解析(有效bd→polyId区域映射双扇对半→帧bbox按扇数竖向均分), 还原真实分格/开向(od角度/inward空心标记/门标注), 无图纸订单显示引导提示 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/views/*, doc/API.md) - [2026-09-20 10:39] FEAT: v0.5 画布尺寸编辑+自动报价+扇样式保真修复——①参数化尺寸编辑: W/H输入框与拖拽外框右缘/下缘/右下角手柄, 全部几何按比例自动缩放(料宽不变,最小300mm), 拖拽静默/松手提交, 撤销栈; ②自动报价: 画布变化600ms防抖自动调offer-price并实时显示总价(样板库/订单详情); ③修复"样板显示为默认样式": 扇样式推断引擎三级解析(有效bd→polyId区域映射双扇对半→帧bbox按扇数竖向均分), 还原真实分格/开向(od角度/inward空心标记/门标注), 无图纸订单显示引导提示 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/views/*, doc/API.md)
+23
View File
@@ -332,6 +332,29 @@
**响应式适配**:侧边栏 <900px 自动折叠为图标栏;<700px 主区边距收窄;表格列横向滚动。 **响应式适配**:侧边栏 <900px 自动折叠为图标栏;<700px 主区边距收窄;表格列横向滚动。
### 12.14 平台扩展模块(v0.8,`/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) |
| 设备列表 | GET | `/api/devices` | — | |
| 生成订单分享 | POST | `/api/share-order/{id}` | — | HMAC 可逆 token |
| **公开订单摘要** | GET | `/api/shared-order/{token}` | 无需登录 | 分享页数据(对齐 customOrder-share) |
| 帮助文章 | GET | `/api/articles?category=` | query | 对齐原站 articleList |
新增前端能力(v0.8):
- **效果图渲染**(`/rendering`):自研 Canvas 场景合成(墙面色/玻璃渐变高光/投影,室内外影棚三场景),设置持久化
- **3D 展示**(`/threed`):自研 Three.js 实现——分割重放区域挤出为玻璃板 + 型材盒体 + 地面网格,自实现轨道控制(旋转/缩放/平移)
- **打印中心**(`/printer`):A4 生产订单模板(图纸缩略图+进度+签字栏),打印 CSS 隔离
- **下料方案**(`/cut_solution`):合同级汇总下料
- **设备管理**(`/devices`)、**订单分享**(`/share/order/:token` 公开页)、登录页**验证码登录** Tab(60s 倒计时)
### 12.13 种子数据 ### 12.13 种子数据
```bash ```bash
+58
View File
@@ -9,9 +9,11 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.0", "@element-plus/icons-vue": "^2.3.0",
"@types/three": "^0.186.0",
"axios": "^1.7.0", "axios": "^1.7.0",
"element-plus": "^2.9.0", "element-plus": "^2.9.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",
"three": "^0.186.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
}, },
@@ -76,6 +78,12 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@element-plus/icons-vue": { "node_modules/@element-plus/icons-vue": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
@@ -927,6 +935,12 @@
"win32" "win32"
] ]
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
@@ -949,12 +963,38 @@
"@types/lodash": "*" "@types/lodash": "*"
} }
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.186.0",
"resolved": "https://registry.npmmirror.com/@types/three/-/three-0.186.0.tgz",
"integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.3",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/web-bluetooth": { "node_modules/@types/web-bluetooth": {
"version": "0.0.21", "version": "0.0.21",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmmirror.com/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.4", "version": "5.2.4",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1361,6 +1401,12 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.16.0", "version": "1.16.0",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -1569,6 +1615,12 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"license": "MIT"
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
@@ -1740,6 +1792,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/three": {
"version": "0.186.0",
"resolved": "https://registry.npmmirror.com/three/-/three-0.186.0.tgz",
"integrity": "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+3 -1
View File
@@ -9,10 +9,12 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"@types/three": "^0.186.0",
"axios": "^1.7.0", "axios": "^1.7.0",
"element-plus": "^2.9.0", "element-plus": "^2.9.0",
"@element-plus/icons-vue": "^2.3.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",
"three": "^0.186.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
}, },
+112
View File
@@ -0,0 +1,112 @@
<template>
<canvas ref="cvRef" class="render-canvas" />
</template>
<script setup lang="ts">
/**
* 简易效果图渲染(自研 Canvas 场景合成):
* 在 2D 窗型几何上叠加玻璃渐变/高光/投影/墙面光影,输出照片观感。
*/
import { onMounted, ref, watch } from 'vue'
import { bboxOf, parseDrawing, regionPoints, replayShape, type DrawingData } from '../types/drawing'
const props = defineProps<{ drawingData: string | object; tint?: string }>()
const cvRef = ref<HTMLCanvasElement>()
function render() {
const cv = cvRef.value!
const wrap = cv.parentElement!
const dpr = window.devicePixelRatio || 1
cv.width = wrap.clientWidth * dpr
cv.height = wrap.clientHeight * dpr
const ctx = cv.getContext('2d')!
ctx.scale(dpr, dpr)
const W = wrap.clientWidth, H = wrap.clientHeight
ctx.clearRect(0, 0, W, H)
let dd: DrawingData
try { dd = parseDrawing(props.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 pad = 30
const scale = Math.min((W - pad * 2) / gw, (H - pad * 2) / gh)
const ox = (W - gw * scale) / 2, oy = (H - gh * scale) / 2
const tx = (x: number) => ox + (x - gb.x0) * scale
const ty = (y: number) => oy + (gb.y1 - y) * scale
const tint = props.tint ?? '#cfe3ee'
// 外墙投影
ctx.save()
ctx.shadowColor = 'rgba(0,0,0,0.35)'
ctx.shadowBlur = 26
ctx.shadowOffsetY = 10
ctx.fillStyle = '#fdfdfc'
ctx.fillRect(tx(gb.x0) - 14, ty(gb.y1) - 14, gw * scale + 28, gh * scale + 28)
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), gw2 = (bb.x1 - bb.x0) * scale, gh2 = (bb.y1 - bb.y0) * scale
const grad = ctx.createLinearGradient(gx, gy, gx + gw2, gy + gh2)
grad.addColorStop(0, tint)
grad.addColorStop(0.45, shade(tint, 18))
grad.addColorStop(0.55, shade(tint, -12))
grad.addColorStop(1, shade(tint, 6))
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.fillStyle = grad
ctx.fill()
// 玻璃斜向高光
ctx.save()
ctx.clip()
ctx.globalAlpha = 0.16
ctx.fillStyle = '#ffffff'
ctx.beginPath()
ctx.moveTo(gx + gw2 * 0.1, gy)
ctx.lineTo(gx + gw2 * 0.38, gy)
ctx.lineTo(gx + gw2 * 0.1, gy + gh2)
ctx.lineTo(gx - gw2 * 0.05, gy + gh2)
ctx.closePath()
ctx.fill()
ctx.restore()
// 型材线
ctx.strokeStyle = '#5c6166'
ctx.lineWidth = 2.2
ctx.stroke()
}
// 外框
const all = dd.sm.flatMap(s => replayShape(s).regions.flat().map(seg => seg.ps))
void all
}
// 整体外框加粗
const outer = bboxOf(pts)
ctx.strokeStyle = '#4a4e52'
ctx.lineWidth = 5
ctx.strokeRect(tx(outer.x0) - 4, ty(outer.y1) - 4, (outer.x1 - outer.x0) * scale + 8, (outer.y1 - outer.y0) * scale + 8)
}
/** 颜色明度调整(+亮/-暗) */
function shade(hex: string, amt: number): string {
const n = parseInt(hex.slice(1), 16)
const r = Math.min(255, Math.max(0, (n >> 16) + amt))
const g = Math.min(255, Math.max(0, ((n >> 8) & 255) + amt))
const b = Math.min(255, Math.max(0, (n & 255) + amt))
return `rgb(${r},${g},${b})`
}
onMounted(() => {
watch(() => [props.drawingData, props.tint], render, { immediate: true })
window.addEventListener('resize', render)
})
</script>
<style scoped>
.render-canvas { width: 100%; height: 100%; display: block; }
</style>
+196
View File
@@ -0,0 +1,196 @@
<template>
<div class="threed-wrap" ref="wrapRef">
<canvas ref="cvRef" />
<div class="hud3d">
<span v-if="info">{{ info }}</span>
<span class="hint">左键旋转 · 滚轮缩放 · 右键平移</span>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 3D 窗型查看器(自研 Three.js 实现)
* 从 drawing_data 重建:外框/中梃 → 型材盒体;玻璃区域 → 半透明玻璃板;
* ArcBall 轨道控制(自实现,不依赖 three examples 以控制体积)。
*/
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import * as THREE from 'three'
import { bboxOf, parseDrawing, regionPoints, replayShape, type DrawingData, type Pt } from '../types/drawing'
const props = defineProps<{ drawingData: string | object }>()
const cvRef = ref<HTMLCanvasElement>()
const wrapRef = ref<HTMLDivElement>()
const info = ref('')
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let raf = 0
let orbit = { theta: -0.7, phi: 1.1, dist: 1.6, target: new THREE.Vector3(0, 0, 0) }
let dragging: { x: number; y: number; button: number } | null = null
function dispose() {
if (scene) {
scene.traverse(obj => {
const m = obj as THREE.Mesh
if (m.geometry) m.geometry.dispose()
const mat = m.material as THREE.Material | THREE.Material[] | undefined
if (Array.isArray(mat)) mat.forEach(x => x.dispose())
else mat?.dispose()
})
scene.clear()
}
}
function build(drawing: DrawingData) {
dispose()
scene = new THREE.Scene()
scene.background = new THREE.Color(0xf2f4f7)
scene.add(new THREE.AmbientLight(0xffffff, 0.75))
const dir = new THREE.DirectionalLight(0xffffff, 0.9)
dir.position.set(1, 2, 3)
scene.add(dir)
// 全局包围盒(取全部基底区域)
const pts: Pt[] = []
for (const s of drawing.sm) {
for (const r of replayShape(s).regions) {
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
}
}
if (!pts.length) { info.value = '无几何数据'; return }
const gb = bboxOf(pts)
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
const maxDim = Math.max(gw, gh)
const S = 1 / maxDim // 归一化到 1 单位
const cx = (gb.x0 + gb.x1) / 2, cy = (gb.y0 + gb.y1) / 2
const DEPTH = Math.max(0.04, (drawing.s?.cs ?? 60) * S * 1.2) // 型材厚度
const GLASS_T = 0.008
const frameMat = new THREE.MeshStandardMaterial({ color: 0x636568, roughness: 0.55, metalness: 0.35 })
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0xdfeef5, transparent: true, opacity: 0.42, roughness: 0.08,
metalness: 0.05, side: THREE.DoubleSide,
})
for (const shape of drawing.sm) {
const { regions } = replayShape(shape)
if (!regions.length) continue
const cm = shape.cm ?? {}
const gm = cm.g
? new THREE.MeshPhysicalMaterial({
color: new THREE.Color(cm.g.replace(/rgba?\(([^)]+)\)/, (_, v: string) => {
const parts = v.split(',').map(x => parseFloat(x))
return `rgb(${parts[0]},${parts[1]},${parts[2]})`
})),
transparent: true, opacity: 0.45, roughness: 0.1, side: THREE.DoubleSide,
})
: glassMat
for (const region of regions) {
const rpts = regionPoints(region)
const bb = bboxOf(rpts)
const w = (bb.x1 - bb.x0) * S, h = (bb.y1 - bb.y0) * S
const mx = ((bb.x0 + bb.x1) / 2 - cx) * S, my = ((bb.y0 + bb.y1) / 2 - cy) * S
// 玻璃板
const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, GLASS_T), gm)
glass.position.set(mx, my, 0)
scene!.add(glass)
// 四周型材框(盒体,宽度=料宽 8% 视觉比例)
const fw = Math.max(0.02, (shape.fm?.ew?.[0] ?? 60) * S)
const mk = (bw: number, bh: number, ox: number, oy: number) => {
const bar = new THREE.Mesh(new THREE.BoxGeometry(bw, bh, DEPTH), frameMat)
bar.position.set(mx + ox, my + oy, 0)
scene!.add(bar)
}
mk(w + fw * 2, fw, 0, h / 2 + fw / 2) // 下(屏幕y翻转后为底)
mk(w + fw * 2, fw, 0, -h / 2 - fw / 2) // 上
mk(fw, h, -w / 2 - fw / 2, 0) // 左
mk(fw, h, w / 2 + fw / 2, 0) // 右
}
}
// 地面参考网格(模型已归一化到 1 单位,网格取 2 单位)
const grid = new THREE.GridHelper(2, 10, 0xc8cdd2, 0xdfe3e8)
grid.position.y = -gh * S / 2 - DEPTH
scene.add(grid)
camera!.position.set(
orbit.target.x + orbit.dist * Math.sin(orbit.phi) * Math.sin(orbit.theta),
orbit.target.y + orbit.dist * Math.cos(orbit.phi),
orbit.target.z + orbit.dist * Math.sin(orbit.phi) * Math.cos(orbit.theta))
camera!.lookAt(orbit.target)
info.value = `${Math.round(gw)} × ${Math.round(gh)} mm`
}
function loop() {
raf = requestAnimationFrame(loop)
if (!renderer || !scene || !camera) return
camera.position.set(
orbit.target.x + orbit.dist * Math.sin(orbit.phi) * Math.sin(orbit.theta),
orbit.target.y + orbit.dist * Math.cos(orbit.phi),
orbit.target.z + orbit.dist * Math.sin(orbit.phi) * Math.cos(orbit.theta))
camera.lookAt(orbit.target)
renderer.render(scene, camera)
}
function onResize() {
const wrap = wrapRef.value, cv = cvRef.value
if (!wrap || !cv || !renderer || !camera) return
renderer.setSize(wrap.clientWidth, wrap.clientHeight)
camera.aspect = wrap.clientWidth / wrap.clientHeight
camera.updateProjectionMatrix()
}
function onDown(e: MouseEvent) { dragging = { x: e.clientX, y: e.clientY, button: e.button } }
function onMove(e: MouseEvent) {
if (!dragging) return
const dx = e.clientX - dragging.x, dy = e.clientY - dragging.y
dragging = { x: e.clientX, y: e.clientY, button: dragging.button }
if (dragging.button === 2) {
// 平移
const pan = new THREE.Vector3().crossVectors(camera!.up, camera!.getWorldDirection(new THREE.Vector3()))
orbit.target.addScaledVector(pan, -dx * orbit.dist * 0.001)
orbit.target.y += dy * orbit.dist * 0.001
} else {
orbit.theta -= dx * 0.008
orbit.phi = Math.min(Math.PI - 0.1, Math.max(0.15, orbit.phi + dy * 0.008))
}
}
function onUp() { dragging = null }
function onWheel(e: WheelEvent) {
orbit.dist = Math.min(6, Math.max(0.3, orbit.dist * (e.deltaY > 0 ? 1.1 : 0.9)))
}
onMounted(() => {
const wrap = wrapRef.value!, cv = cvRef.value!
renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: true })
renderer.setSize(wrap.clientWidth, wrap.clientHeight)
camera = new THREE.PerspectiveCamera(50, wrap.clientWidth / wrap.clientHeight, 0.01, 100)
cv.addEventListener('mousedown', onDown)
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
cv.addEventListener('wheel', e => { e.preventDefault(); onWheel(e) }, { passive: false })
cv.addEventListener('contextmenu', e => e.preventDefault())
window.addEventListener('resize', onResize)
watch(() => props.drawingData, () => {
try { if (props.drawingData) build(parseDrawing(props.drawingData)) } catch { info.value = '解析失败' }
}, { immediate: true })
loop()
})
onBeforeUnmount(() => {
cancelAnimationFrame(raf)
dispose()
window.removeEventListener('resize', onResize)
renderer?.dispose()
})
</script>
<style scoped>
.threed-wrap { position: relative; width: 100%; height: 100%; min-height: 320px; }
canvas { width: 100%; height: 100%; display: block; }
.hud3d { position: absolute; right: 10px; top: 10px; background: rgba(255,255,255,0.9);
border-radius: 6px; padding: 4px 10px; font-size: 12px; color: #606266; display: flex; gap: 12px; }
.hint { color: #b0b5bc; }
</style>
+8
View File
@@ -23,16 +23,24 @@ const router = createRouter({
{ path: 'products', component: () => import('../views/ProductsView.vue'), meta: { title: '产品管理' } }, { path: 'products', component: () => import('../views/ProductsView.vue'), meta: { title: '产品管理' } },
{ path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } }, { path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } },
{ path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } }, { path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
{ path: 'rendering', component: () => import('../views/RenderView.vue'), meta: { title: '效果图渲染' } },
{ 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: '下料方案' } },
{ path: 'devices', component: () => import('../views/DevicesView.vue'), meta: { title: '设备管理' } },
{ path: 'market', component: () => import('../views/MarketView.vue'), meta: { title: '商城' } }, { path: 'market', component: () => import('../views/MarketView.vue'), meta: { title: '商城' } },
{ path: 'guide', component: () => import('../views/GuideView.vue'), meta: { title: '使用指南' } }, { path: 'guide', component: () => import('../views/GuideView.vue'), meta: { title: '使用指南' } },
{ path: 'personal', component: () => import('../views/PersonalView.vue'), meta: { title: '个人中心' } }, { path: 'personal', component: () => import('../views/PersonalView.vue'), meta: { title: '个人中心' } },
], ],
}, },
{ path: '/share/order/:token', component: () => import('../views/ShareOrderView.vue') },
], ],
}) })
router.beforeEach(to => { router.beforeEach(to => {
const auth = useAuthStore() const auth = useAuthStore()
// 公开页:分享订单
if (to.path.startsWith('/share/')) return true
if (to.path !== '/login' && !auth.isLogin) return '/login' if (to.path !== '/login' && !auth.isLogin) return '/login'
return true return true
}) })
+64
View File
@@ -0,0 +1,64 @@
<template>
<el-card>
<div class="toolbar">
<el-select v-model="contractId" filterable placeholder="选择合同" style="width:300px">
<el-option v-for="c in contracts" :key="c.id" :label="`${c.contract_no} ${c.customer_name || ''}`" :value="c.id" />
</el-select>
<span>锯口</span>
<el-input-number v-model="kerf" :min="0" :max="50" size="small" controls-position="right" style="width:90px" />
<el-button type="primary" :loading="loading" :disabled="!contractId" @click="run">生成下料方案</el-button>
</div>
<template v-if="result">
<el-alert type="success" :closable="false" show-icon
:title="`合同 ${contractNo} · ${result.order_count} 个订单 · 利用率 ${(result.stats.utilization * 100).toFixed(1)}% · 用料 ${result.stats.stock_bars_used} 根`"
style="margin-bottom:12px" />
<el-table :data="result.plans" border size="small">
<el-table-column label="原料" width="180">
<template #default="{ row }">{{ row.stock_label }}({{ row.stock_length }}mm)</template>
</el-table-column>
<el-table-column label="切割组合">
<template #default="{ row }">
<el-tag v-for="(c, i) in row.pieces" :key="i" size="small" style="margin:2px">
{{ c.label || '件' }} {{ c.length }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="waste" label="余料mm" width="90" />
</el-table>
<el-alert v-if="result.unplaced.length" type="warning" :closable="false"
:title="`未排入 ${result.unplaced.length} 件(库存不足)`" style="margin-top:10px" />
</template>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
const contracts = ref<any[]>([])
const contractId = ref<number | undefined>()
const contractNo = ref('')
const kerf = ref(5)
const loading = ref(false)
const result = ref<any>(null)
async function run() {
loading.value = true
try {
const d = await api<any>('post', '/api/cut_solution', { contract_id: contractId.value, kerf: kerf.value })
result.value = d
contractNo.value = d.contract_no
ElMessage.success(`已生成:利用率 ${(d.stats.utilization * 100).toFixed(1)}%`)
} finally { loading.value = false }
}
onMounted(async () => {
const d = await api<any>('get', '/api/contracts?page=1&page_size=100')
contracts.value = d.contracts?.data ?? []
})
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
</style>
+58
View File
@@ -0,0 +1,58 @@
<template>
<el-row :gutter="16">
<el-col :xs="24" :md="14">
<el-card header="我的设备">
<el-table :data="devices" stripe size="small">
<el-table-column prop="sn" label="序列号" min-width="140" />
<el-table-column prop="name" label="名称" min-width="110" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '已激活' : '未激活' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="activated_at" label="激活时间" width="160" />
</el-table>
<el-empty v-if="!devices.length" description="暂无设备,输入序列号与激活码绑定" :image-size="60" />
</el-card>
</el-col>
<el-col :xs="24" :md="10">
<el-card header="设备激活绑定">
<el-form :model="form" label-width="80px">
<el-form-item label="序列号"><el-input v-model="form.sn" placeholder="设备SN" /></el-form-item>
<el-form-item label="激活码"><el-input v-model="form.activate_code" placeholder="随机激活码(首绑自动生成)" /></el-form-item>
<el-form-item label="名称"><el-input v-model="form.name" placeholder="设备备注名" /></el-form-item>
<el-button type="primary" @click="activate">激活绑定</el-button>
</el-form>
<p class="tip">设备对接协议(开料机/切割锯)通过激活码与账户绑定,对接细节见使用指南。</p>
</el-card>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
const devices = ref<any[]>([])
const form = reactive({ sn: '', activate_code: '', name: '' })
async function load() {
devices.value = await api<any>('get', '/api/devices')
}
async function activate() {
if (!form.sn) return ElMessage.warning('请输入序列号')
await api('post', '/api/device-activate', form)
ElMessage.success('激活成功')
form.sn = form.activate_code = form.name = ''
load()
}
onMounted(load)
</script>
<style scoped>
.tip { color: #909399; font-size: 12px; margin-top: 10px; }
</style>
+5
View File
@@ -16,6 +16,11 @@
<el-menu-item index="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item> <el-menu-item index="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item>
<el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item> <el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item>
<el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item> <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="/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>
<el-menu-item index="/market"><el-icon><ShoppingCart /></el-icon>商城</el-menu-item> <el-menu-item index="/market"><el-icon><ShoppingCart /></el-icon>商城</el-menu-item>
<el-menu-item index="/guide"><el-icon><QuestionFilled /></el-icon>使用指南</el-menu-item> <el-menu-item index="/guide"><el-icon><QuestionFilled /></el-icon>使用指南</el-menu-item>
</el-menu> </el-menu>
+44
View File
@@ -13,6 +13,23 @@
<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 />
</el-form-item> </el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">登 录</el-button> <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-form> </el-form>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="注册" name="register"> <el-tab-pane label="注册" name="register">
@@ -48,6 +65,33 @@ const loading = ref(false)
const tab = ref('login') const tab = ref('login')
const loginForm = reactive({ phone_number: '', password: '' }) const loginForm = reactive({ phone_number: '', password: '' })
const regForm = reactive({ phone_number: '', password: '', invite_code: '' }) 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() { async function doLogin() {
if (!loginForm.phone_number || !loginForm.password) return ElMessage.warning('请输入手机号和密码') if (!loginForm.phone_number || !loginForm.password) return ElMessage.warning('请输入手机号和密码')
+11 -1
View File
@@ -24,11 +24,12 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" /> <el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="230" fixed="right"> <el-table-column label="操作" width="290" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="openDetail(row)">图纸</el-button> <el-button size="small" @click="openDetail(row)">图纸</el-button>
<el-button v-for="act in nextActions(row.status)" :key="act.to" size="small" <el-button v-for="act in nextActions(row.status)" :key="act.to" size="small"
:type="act.type" @click="changeStatus(row, act.to)">{{ act.label }}</el-button> :type="act.type" @click="changeStatus(row, act.to)">{{ act.label }}</el-button>
<el-button size="small" type="success" plain @click="share(row)">分享</el-button>
<el-button size="small" type="danger" plain @click="remove(row)">删</el-button> <el-button size="small" type="danger" plain @click="remove(row)">删</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -165,6 +166,15 @@ async function remove(row: any) {
load() load()
} }
async function share(row: any) {
const d = await api<any>('post', `/api/share-order/${row.id}`)
const url = `${location.origin}${d.url}`
await navigator.clipboard?.writeText(url).catch(() => undefined)
ElMessageBox.alert(
`分享链接已复制:<br/><a href="${url}" target="_blank">${url}</a><br/><br/>客户打开后可免登录查看订单摘要。`,
'分享订单', { dangerouslyUseHTMLString: true })
}
// ---- 新建 ---- // ---- 新建 ----
const createVisible = ref(false) const createVisible = ref(false)
const saving = ref(false) const saving = ref(false)
+85
View File
@@ -0,0 +1,85 @@
<template>
<el-card>
<div class="toolbar no-print">
<el-select v-model="orderType" style="width:140px" @change="load">
<el-option label="门窗订单" :value="0" />
<el-option label="阳光房订单" :value="1" />
</el-select>
<el-select v-model="orderId" filterable placeholder="选择订单" style="width:280px" @change="onPick">
<el-option v-for="o in orders" :key="o.id" :label="`${o.order_no} ${o.customer_name || ''}`" :value="o.id" />
</el-select>
<el-button type="primary" :disabled="!current" @click="print">打印</el-button>
</div>
<div v-if="current" class="a4" id="print-area">
<h2 class="doc-title">生产订单</h2>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="订单号">{{ current.order_no }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ current.customer_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ current.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="地址">{{ current.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ current.count }}</el-descriptions-item>
<el-descriptions-item label="金额">¥{{ current.money }}</el-descriptions-item>
</el-descriptions>
<div class="pic-box">
<img v-if="current.pic_url" :src="current.pic_url" alt="图纸" />
<div v-else class="no-pic">暂无图纸缩略图</div>
</div>
<el-divider>生产进度</el-divider>
<table class="simple">
<tr v-for="p in current.processes" :key="p.name">
<td style="width:90px">{{ p.name }}</td>
<td>{{ p.status === 2 ? `已完成 ${p.finish_time || ''}` : p.status === 1 ? '进行中' : '未开始' }}</td>
</tr>
</table>
<p class="sign">客户签字:____________  日期:____________</p>
</div>
<el-empty v-else description="选择订单后预览打印" />
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api'
const orders = ref<any[]>([])
const orderId = ref<number | undefined>()
const orderType = ref(0)
const current = ref<any>(null)
async function load() {
const d = orderType.value === 1
? await api<any>('get', `/api/sunny_house_orders?page=1&page_size=100`)
: await api<any>('post', '/api/orders/search', { page: 1, page_size: 100 })
orders.value = orderType.value === 1 ? d.data : d.data
current.value = null
}
async function onPick(oid: number | undefined) {
if (!oid) { current.value = null; return }
current.value = await api<any>('get', `/api/orders/${oid}`)
}
function print() { window.print() }
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.a4 { width: 210mm; min-height: 240mm; margin: 0 auto; background: #fff; padding: 14mm; box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.doc-title { text-align: center; letter-spacing: 8px; }
.pic-box { height: 80mm; display: flex; align-items: center; justify-content: center; background: #f7f8f9; margin-top: 12px; }
.pic-box img { max-width: 100%; max-height: 100%; }
.no-pic { color: #909399; }
.simple { width: 100%; border-collapse: collapse; }
.simple td { border: 1px solid #dcdfe6; padding: 5px 8px; font-size: 13px; }
.sign { margin-top: 30px; text-align: right; color: #606266; }
@media print {
.no-print { display: none !important; }
.a4 { box-shadow: none; width: 100%; }
body * { visibility: hidden; }
#print-area, #print-area * { visibility: visible; }
#print-area { position: absolute; left: 0; top: 0; }
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<el-card>
<template #header>
<div class="head">
<el-radio-group v-model="mode" size="small">
<el-radio-button value="render">效果图渲染</el-radio-button>
<el-radio-button value="threed">3D 展示</el-radio-button>
</el-radio-group>
<div class="sel">
<el-select v-model="templateId" filterable placeholder="选择样板" style="width:200px" @change="loadTemplate">
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
</el-select>
</div>
</div>
</template>
<!-- 渲染设置 -->
<div class="settings" v-if="mode === 'render'">
<span>墙面色</span><el-color-picker v-model="setting.wall_color" size="small" @change="saveSetting" />
<span>玻璃色</span><el-color-picker v-model="setting.glass_tint" size="small" @change="saveSetting" />
<span>场景</span>
<el-radio-group v-model="setting.scene" size="small" @change="saveSetting">
<el-radio-button value="indoor">室内</el-radio-button>
<el-radio-button value="outdoor">室外</el-radio-button>
<el-radio-button value="studio">影棚</el-radio-button>
</el-radio-group>
<el-button size="small" type="primary" @click="print">导出图片</el-button>
</div>
<div class="view-box">
<!-- 2D 效果图渲染(自研 Canvas 场景合成) -->
<div v-show="mode === 'render'" class="render-stage" :style="{ background: sceneBg }">
<div class="wall" :style="{ background: `linear-gradient(180deg, ${setting.wall_color}, #e8e2d8)` }">
<div v-if="drawing" class="window-frame">
<RenderCanvas :drawing-data="drawing" :tint="setting.glass_tint" />
</div>
<div v-else class="empty">请选择样板</div>
</div>
</div>
<!-- 3D -->
<WindoorThreed v-show="mode === 'threed'" :drawing-data="drawing ?? ''" class="threed-box" />
</div>
<p class="note">注:当前为本地实时渲染;照片级 AI 渲染需接入第三方渲染服务(规划中)。</p>
</el-card>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { api } from '../api'
import WindoorThreed from '../components/WindoorThreed.vue'
import RenderCanvas from '../components/RenderCanvas.vue'
const mode = ref<'render' | 'threed'>('render')
const templates = ref<any[]>([])
const templateId = ref<number | undefined>()
const drawing = ref<string>('')
const setting = reactive({ wall_color: '#f5f0e8', scene: 'indoor', glass_tint: '#cfe3ee' })
const sceneBg = computed(() => ({
indoor: 'linear-gradient(180deg,#efe9df,#dcd3c5)',
outdoor: 'linear-gradient(180deg,#bcd6ea,#8fb6d6)',
studio: 'linear-gradient(180deg,#f2f2f2,#d9d9d9)',
} as Record<string, string>)[setting.scene] ?? '#eee')
async function loadTemplate(tid: number | undefined) {
if (!tid) { drawing.value = ''; return }
const t = await api<any>('get', `/api/windoor/templates/${tid}`)
drawing.value = t.drawing_data
}
async function saveSetting() {
await api('post', '/api/rendering-info', setting)
}
onMounted(async () => {
templates.value = await api<any>('get', '/api/windoor/templates?page=1&page_size=254')
Object.assign(setting, await api<any>('get', '/api/rendering-info'))
})
function print() { window.print() }
</script>
<style scoped>
.head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; }
.sel { display: flex; gap: 8px; }
.settings { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; font-size: 13px; color: #606266; flex-wrap: wrap; }
.view-box { height: 60vh; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
.render-stage { height: 100%; padding: 24px; display: flex; align-items: center; justify-content: center; }
.wall { width: min(720px, 100%); height: 100%; border-radius: 8px; box-shadow: 0 6px 24px rgba(0,0,0,.18);
display: flex; align-items: center; justify-content: center; }
.window-frame { width: 78%; height: 82%; background: #fff; border-radius: 4px; padding: 10px;
box-shadow: inset 0 0 12px rgba(0,0,0,.25); }
.threed-box { height: 100%; }
.empty { color: #909399; }
.note { color: #b0b5bc; font-size: 12px; margin-top: 8px; }
@media print { .settings, .head { display: none; } }
</style>
+50
View File
@@ -0,0 +1,50 @@
<template>
<div class="share-page">
<el-card class="share-card" v-loading="loading">
<template v-if="order">
<h2>订单查询</h2>
<p class="no">{{ order.order_no }}</p>
<el-descriptions :column="1" border>
<el-descriptions-item label="状态">
<el-tag size="small">{{ ['意向单', '正式单', '生产中', '已完工', '已取消'][order.status] }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="数量">{{ order.count }}</el-descriptions-item>
<el-descriptions-item label="面积">{{ order.area }} ㎡</el-descriptions-item>
<el-descriptions-item label="下单时间">{{ order.created_at }}</el-descriptions-item>
</el-descriptions>
<img v-if="order.pic_url" :src="order.pic_url" class="pic" alt="订单图纸" />
<p class="tip">此页面为只读订单分享页,如需修改请联系商家。</p>
</template>
<el-empty v-else-if="!loading" description="订单不存在或未开放分享" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const route = useRoute()
const order = ref<any>(null)
const loading = ref(true)
onMounted(async () => {
try {
// 公开接口:不带登录态
const res = await axios.get(`/api/shared-order/${route.params.token}`)
if (res.data.code === 1) order.value = res.data.data
else ElMessage.error(res.data.message)
} catch { /* ignore */ } finally { loading.value = false }
})
</script>
<style scoped>
.share-page { min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #35506b, #6b8cae); }
.share-card { width: 420px; text-align: center; }
.no { font-size: 20px; font-weight: 700; color: #303133; letter-spacing: 2px; }
.pic { max-width: 100%; max-height: 300px; margin-top: 12px; }
.tip { color: #909399; font-size: 12px; margin-top: 10px; }
</style>
+291
View File
@@ -0,0 +1,291 @@
"""平台扩展路由:验证码登录、支付、渲染设置、下料方案、设备、分享、文章。"""
import datetime
import random
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, 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.models.contract import Contract
from huamenchuang.models.extend import Article, CompanyParam, Device, PayOrder, SmsCode
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))
# ---------------- 渲染设置 + 简易渲染参数 ----------------
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}$")
@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"})
@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()
if p is None:
p = CompanyParam(company_id=user.company_id, key="render_setting")
db.add(p)
p.value = _json.dumps(body.model_dump())
return ok(data=body.model_dump(), message="渲染设置已保存")
# ---------------- 下料方案(cut_solution) ----------------
class CutSolutionBody(BaseModel):
contract_id: int
kerf: int = Field(default=5, ge=0, le=50)
@router.post("/cut_solution")
async def cut_solution(body: CutSolutionBody, user: CurrentUser, db: DbSession):
"""合同下料方案:汇总合同下订单的算料需求,执行一维优化并保存参数。"""
contract = await db.get(Contract, body.contract_id)
if contract is None or contract.company_id != user.company_id:
return fail("合同不存在")
stmt = select(Order).where(Order.contract_id == body.contract_id,
Order.company_id == user.company_id)
orders = list((await db.execute(stmt)).scalars())
demands: dict[tuple, float] = {}
for o in orders:
if not o.drawing_data:
continue
from huamenchuang.core.drawing import parse_drawing
from huamenchuang.services.quote_service import takeoff
t = takeoff(parse_drawing(o.drawing_data))
for b in t.bars:
if b.kind == "glass":
continue
key = (b.length, b.note)
demands[key] = demands.get(key, 0) + b.qty
if not demands:
return fail("该合同下没有可算料的订单图纸")
from huamenchuang.services.optimize_service import BarDemand, StockBar, optimize_1d
dem = [BarDemand(length=k[0], qty=int(v), label=k[1]) for k, v in demands.items()]
stocks = [StockBar(length=6000, qty=max(1, sum(d.qty for d in dem) // 3 + 1), label="6米型材")]
result = optimize_1d(dem, stocks, body.kerf)
await _save_param(db, user.company_id, f"cut_solution_{body.contract_id}",
__import__("json").dumps({"demands": [[k[0], int(v), k[1]] for k, v in demands.items()],
"kerf": body.kerf}, ensure_ascii=False))
return ok(data={"contract_no": contract.contract_no, "order_count": len(orders), **result},
message="下料方案已生成")
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()
if p is None:
p = CompanyParam(company_id=company_id, key=key)
db.add(p)
p.value = value
# ---------------- 设备模块 ----------------
class DeviceBindBody(BaseModel):
sn: str
activate_code: str
name: str = ""
@router.post("/device-activate")
async def device_activate(body: DeviceBindBody, user: CurrentUser, db: DbSession):
"""设备激活绑定:序列号+激活码。"""
stmt = select(Device).where(Device.sn == body.sn)
dev = (await db.execute(stmt)).scalar_one_or_none()
if dev is None:
dev = Device(sn=body.sn, activate_code=body.activate_code, name=body.name or body.sn,
company_id=user.company_id)
db.add(dev)
await db.flush()
if dev.activate_code and dev.activate_code != body.activate_code:
return fail("激活码错误")
if dev.status == 1 and dev.bound_user_id not in (0, user.id):
return fail("设备已被其他账户绑定")
dev.status = 1
dev.bound_user_id = user.id
dev.activated_at = datetime.datetime.utcnow()
return ok(data={"id": dev.id, "sn": dev.sn, "name": dev.name}, message="设备激活成功")
@router.get("/devices")
async def list_devices(user: CurrentUser, db: DbSession):
stmt = select(Device).where(Device.company_id == user.company_id).order_by(desc(Device.id))
items = list((await db.execute(stmt)).scalars())
return ok(data=[{
"id": d.id, "sn": d.sn, "name": d.name, "status": d.status,
"activated_at": str(d.activated_at) if d.activated_at else None,
} for d in items])
# ---------------- 分享订单(customOrder-share 公开只读页数据) ----------------
def _sign(oid: int) -> str:
import hashlib, hmac
from huamenchuang.core.config import settings
return hmac.new(settings.jwt_secret.encode(), str(oid).encode(), hashlib.sha256).hexdigest()[:8]
def make_share_token(order_id: int) -> str:
"""可逆分享 token: base64(id.签名)。"""
import base64
raw = f"{order_id}.{_sign(order_id)}"
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
def parse_share_token(token: str) -> int | None:
import base64
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
except Exception:
return None
import hmac # noqa: E402
@router.get("/shared-order/{token}")
async def shared_order(token: str, db: DbSession):
"""通过分享 token 公开只读订单摘要(无需登录)。"""
oid = parse_share_token(token)
if oid is None:
return fail("分享链接无效")
o = await db.get(Order, oid)
if o is None or not o.drawing_data:
return fail("订单不存在或未开放分享")
return ok(data={
"order_no": o.order_no, "customer_name": o.customer_name, "status": o.status,
"pic_url": o.pic_url, "area": float(o.area), "count": o.count,
"created_at": str(o.created_at),
})
@router.post("/share-order/{order_id}")
async def share_order(order_id: int, user: CurrentUser, db: DbSession):
"""生成订单分享链接 token。"""
o = await db.get(Order, order_id)
if o is None or o.company_id != user.company_id:
return fail("订单不存在")
token = make_share_token(order_id)
return ok(data={"token": token, "url": f"/share/order/{token}"})
# ---------------- 帮助文章 ----------------
@router.get("/articles")
async def list_articles(db: DbSession, category: str | None = None):
conds = []
if category:
conds.append(Article.category == category)
stmt = select(Article).where(*conds).order_by(Article.sort, Article.id)
items = list((await db.execute(stmt)).scalars())
return ok(data=[{"id": a.id, "title": a.title, "category": a.category, "content": a.content}
for a in items])
+2
View File
@@ -8,6 +8,7 @@ from huamenchuang.api import (
customers, customers,
optimize, optimize,
orders, orders,
platform,
quote, quote,
warehouse, warehouse,
windoor, windoor,
@@ -23,3 +24,4 @@ api_router.include_router(optimize.router)
api_router.include_router(quote.router) api_router.include_router(quote.router)
api_router.include_router(warehouse.router) api_router.include_router(warehouse.router)
api_router.include_router(business.router) api_router.include_router(business.router)
api_router.include_router(platform.router)
+81
View File
@@ -0,0 +1,81 @@
"""扩展业务模型:支付单、设备、公司参数、文章、验证码。"""
import datetime
from sqlalchemy import DateTime, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from huamenchuang.core.database import Base
class PayOrder(Base):
"""支付订单(支付通道适配层:微信/支付宝凭据就绪后接通)。"""
__tablename__ = "pay_orders"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
pay_no: Mapped[str] = mapped_column(String(32), unique=True) # PAY+日期+流水
company_id: Mapped[int] = mapped_column(default=1, index=True)
user_id: Mapped[int] = mapped_column(default=0)
subject: Mapped[str] = mapped_column(String(128), default="") # 商品名
amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
channel: Mapped[str] = mapped_column(String(16), default="wechat") # wechat/alipay
status: Mapped[int] = mapped_column(default=0) # 0待支付 1已支付 2已取消
out_trade_no: Mapped[str] = mapped_column(String(64), default="") # 三方流水号
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
paid_at: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
class Device(Base):
"""设备(对齐目标站 device-activate/deviceDocking:激活码绑定)。"""
__tablename__ = "devices"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
name: Mapped[str] = mapped_column(String(64), default="")
sn: Mapped[str] = mapped_column(String(64), unique=True) # 设备序列号
activate_code: Mapped[str] = mapped_column(String(32), default="")
status: Mapped[int] = mapped_column(default=0) # 0未激活 1已激活 2已停用
bound_user_id: Mapped[int] = mapped_column(default=0)
activated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class CompanyParam(Base):
"""公司级参数存储(对齐原站 find/update_company_param_value)。"""
__tablename__ = "company_params"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
key: Mapped[str] = mapped_column(String(64), index=True)
value: Mapped[str] = mapped_column(Text, default="")
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
)
class Article(Base):
"""帮助文章(对齐原站 articleList / 使用指南)。"""
__tablename__ = "articles"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(128))
category: Mapped[str] = mapped_column(String(32), default="常见问题", index=True)
content: Mapped[str] = mapped_column(Text, default="")
sort: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
class SmsCode(Base):
"""短信验证码(provider 适配层:未配置短信签名时走 mock 通道)。"""
__tablename__ = "sms_codes"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
phone_number: Mapped[str] = mapped_column(String(20), index=True)
code: Mapped[str] = mapped_column(String(8))
scene: Mapped[str] = mapped_column(String(16), default="login") # login/reset/bind
used: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
@@ -80,6 +80,7 @@ def optimize_1d(demands: list[BarDemand], stocks: list[StockBar],
for b in avail if b["pieces"] for b in avail if b["pieces"]
] ]
total_demand = sum(p.length for p in pieces) total_demand = sum(p.length for p in pieces)
placed_demand = total_demand - sum(p.length for p in unplaced)
total_stock = sum(p.stock_length for p in plans) total_stock = sum(p.stock_length for p in plans)
return { return {
"plans": [ "plans": [
@@ -98,7 +99,8 @@ def optimize_1d(demands: list[BarDemand], stocks: list[StockBar],
"stock_bars_used": len(plans), "stock_bars_used": len(plans),
"total_demand_mm": total_demand, "total_demand_mm": total_demand,
"total_stock_mm": total_stock, "total_stock_mm": total_stock,
"utilization": round(total_demand / total_stock, 4) if total_stock else 0, # 利用率 = 已排入件长度 / 已用原料长度(恒 ≤ 1)
"utilization": round(placed_demand / total_stock, 4) if total_stock else 0,
"kerf_mm": kerf, "kerf_mm": kerf,
}, },
} }