feat: v0.8 平台扩展——3D渲染/打印中心/下料方案/设备/分享/验证码登录
- 3D展示: Three.js挤出重建窗型(玻璃半透明+型材盒体), 自研轨道控制 - 效果图渲染: Canvas场景合成(墙面/玻璃渐变高光/投影/三场景), 设置持久化 - 打印中心: A4生产订单模板(图纸+进度+签字栏), 打印CSS隔离 - 下料方案: 合同级汇总订单算料需求→FFD优化, 修正利用率为已排入/已用料 - 设备模块: 序列号+激活码绑定, 防重复绑定 - 订单分享: HMAC可逆token + 免登录公开只读页(customOrder-share) - 验证码登录: mock短信通道全流程可用(发码/校验/自动注册), 接签名后换provider - 帮助文章接口; 新增支付单适配层(微信/支付宝凭据就绪即接通) - 前端新增6页面, 共18页面
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
Generated
+58
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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('请输入手机号和密码')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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])
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user