From 3f871d7eeb4039298d880d20af4d97fac0bc3866 Mon Sep 17 00:00:00 2001 From: HHY Date: Sun, 20 Sep 2026 12:10:26 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20v0.8=20=E5=B9=B3=E5=8F=B0=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E2=80=94=E2=80=943D=E6=B8=B2=E6=9F=93/=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E4=B8=AD=E5=BF=83/=E4=B8=8B=E6=96=99=E6=96=B9?= =?UTF-8?q?=E6=A1=88/=E8=AE=BE=E5=A4=87/=E5=88=86=E4=BA=AB/=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3D展示: Three.js挤出重建窗型(玻璃半透明+型材盒体), 自研轨道控制 - 效果图渲染: Canvas场景合成(墙面/玻璃渐变高光/投影/三场景), 设置持久化 - 打印中心: A4生产订单模板(图纸+进度+签字栏), 打印CSS隔离 - 下料方案: 合同级汇总订单算料需求→FFD优化, 修正利用率为已排入/已用料 - 设备模块: 序列号+激活码绑定, 防重复绑定 - 订单分享: HMAC可逆token + 免登录公开只读页(customOrder-share) - 验证码登录: mock短信通道全流程可用(发码/校验/自动注册), 接签名后换provider - 帮助文章接口; 新增支付单适配层(微信/支付宝凭据就绪即接通) - 前端新增6页面, 共18页面 --- CHANGELOG | 1 + doc/API.md | 23 ++ frontend/package-lock.json | 58 ++++ frontend/package.json | 4 +- frontend/src/components/RenderCanvas.vue | 112 +++++++ frontend/src/components/WindoorThreed.vue | 196 ++++++++++++ frontend/src/router/index.ts | 8 + frontend/src/views/CutSolutionView.vue | 64 ++++ frontend/src/views/DevicesView.vue | 58 ++++ frontend/src/views/LayoutView.vue | 5 + frontend/src/views/LoginView.vue | 44 +++ frontend/src/views/OrdersView.vue | 12 +- frontend/src/views/PrinterView.vue | 85 +++++ frontend/src/views/RenderView.vue | 97 ++++++ frontend/src/views/ShareOrderView.vue | 50 +++ src/huamenchuang/api/platform.py | 291 ++++++++++++++++++ src/huamenchuang/api/router.py | 2 + src/huamenchuang/models/extend.py | 81 +++++ src/huamenchuang/services/optimize_service.py | 4 +- 19 files changed, 1192 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/RenderCanvas.vue create mode 100644 frontend/src/components/WindoorThreed.vue create mode 100644 frontend/src/views/CutSolutionView.vue create mode 100644 frontend/src/views/DevicesView.vue create mode 100644 frontend/src/views/PrinterView.vue create mode 100644 frontend/src/views/RenderView.vue create mode 100644 frontend/src/views/ShareOrderView.vue create mode 100644 src/huamenchuang/api/platform.py create mode 100644 src/huamenchuang/models/extend.py diff --git a/CHANGELOG b/CHANGELOG index aedb710..591f006 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 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 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) diff --git a/doc/API.md b/doc/API.md index 6cbfb67..7e84c08 100644 --- a/doc/API.md +++ b/doc/API.md @@ -332,6 +332,29 @@ **响应式适配**:侧边栏 <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 种子数据 ```bash diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f777e28..ae47375 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,9 +9,11 @@ "version": "0.1.0", "dependencies": { "@element-plus/icons-vue": "^2.3.0", + "@types/three": "^0.186.0", "axios": "^1.7.0", "element-plus": "^2.9.0", "pinia": "^2.2.0", + "three": "^0.186.0", "vue": "^3.5.0", "vue-router": "^4.4.0" }, @@ -76,6 +78,12 @@ "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": { "version": "2.3.2", "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", @@ -927,6 +935,12 @@ "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": { "version": "1.0.9", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", @@ -949,12 +963,38 @@ "@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": { "version": "0.0.21", "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", "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": { "version": "5.2.4", "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==", "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": { "version": "1.16.0", "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -1569,6 +1615,12 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "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": { "version": "1.52.0", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", @@ -1740,6 +1792,12 @@ "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": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index f7cbf3c..3ed4d1d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,10 +9,12 @@ "preview": "vite preview" }, "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "@types/three": "^0.186.0", "axios": "^1.7.0", "element-plus": "^2.9.0", - "@element-plus/icons-vue": "^2.3.0", "pinia": "^2.2.0", + "three": "^0.186.0", "vue": "^3.5.0", "vue-router": "^4.4.0" }, diff --git a/frontend/src/components/RenderCanvas.vue b/frontend/src/components/RenderCanvas.vue new file mode 100644 index 0000000..1160759 --- /dev/null +++ b/frontend/src/components/RenderCanvas.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/src/components/WindoorThreed.vue b/frontend/src/components/WindoorThreed.vue new file mode 100644 index 0000000..a54a715 --- /dev/null +++ b/frontend/src/components/WindoorThreed.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index abb8670..42ce24e 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -23,16 +23,24 @@ const router = createRouter({ { path: 'products', component: () => import('../views/ProductsView.vue'), meta: { title: '产品管理' } }, { path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } }, { path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } }, + { path: 'rendering', component: () => import('../views/RenderView.vue'), meta: { title: '效果图渲染' } }, + { path: '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: 'guide', component: () => import('../views/GuideView.vue'), meta: { title: '使用指南' } }, { path: 'personal', component: () => import('../views/PersonalView.vue'), meta: { title: '个人中心' } }, ], }, + { path: '/share/order/:token', component: () => import('../views/ShareOrderView.vue') }, ], }) router.beforeEach(to => { const auth = useAuthStore() + // 公开页:分享订单 + if (to.path.startsWith('/share/')) return true if (to.path !== '/login' && !auth.isLogin) return '/login' return true }) diff --git a/frontend/src/views/CutSolutionView.vue b/frontend/src/views/CutSolutionView.vue new file mode 100644 index 0000000..16082ac --- /dev/null +++ b/frontend/src/views/CutSolutionView.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/views/DevicesView.vue b/frontend/src/views/DevicesView.vue new file mode 100644 index 0000000..239019a --- /dev/null +++ b/frontend/src/views/DevicesView.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/frontend/src/views/LayoutView.vue b/frontend/src/views/LayoutView.vue index b67679b..b9637fd 100644 --- a/frontend/src/views/LayoutView.vue +++ b/frontend/src/views/LayoutView.vue @@ -16,6 +16,11 @@ 产品管理 仓库库存 优化下料 + 下料方案 + 效果图渲染 + 3D 展示 + 打印中心 + 设备管理 商城 使用指南 diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 6e12a2e..dd19d68 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -13,6 +13,23 @@ 登 录 + 验证码登录 + + + + + + + + +
+ + + {{ countdown > 0 ? `${countdown}s` : '获取验证码' }} +
+
+ 登 录 +

开发提示:mock 验证码 {{ mockCode }}(见服务端日志)

@@ -48,6 +65,33 @@ const loading = ref(false) const tab = ref('login') const loginForm = reactive({ phone_number: '', password: '' }) const regForm = reactive({ phone_number: '', password: '', invite_code: '' }) +const codeForm = reactive({ phone_number: '', code: '' }) +const countdown = ref(0) +const mockCode = ref('') +let cdTimer: number | undefined + +async function sendCode() { + if (!codeForm.phone_number) return ElMessage.warning('请输入手机号') + await api('post', '/api/verification-code-new', { phone_number: codeForm.phone_number, scene: 'login' }) + ElMessage.success('验证码已发送(开发环境见服务端日志)') + // 开发环境后端走 mock 通道:尝试从响应头拿不到就提示看日志 + mockCode.value = '' + countdown.value = 60 + cdTimer = window.setInterval(() => { + countdown.value-- + if (countdown.value <= 0) clearInterval(cdTimer) + }, 1000) +} + +async function doCodeLogin() { + if (!codeForm.phone_number || !codeForm.code) return ElMessage.warning('请输入手机号和验证码') + loading.value = true + try { + const d = await api<{ token: string; user: { nickname: string } }>('post', '/api/code-login', codeForm) + auth.setSession(d.token, d.user?.nickname ?? '') + router.push('/dashboard') + } finally { loading.value = false } +} async function doLogin() { if (!loginForm.phone_number || !loginForm.password) return ElMessage.warning('请输入手机号和密码') diff --git a/frontend/src/views/OrdersView.vue b/frontend/src/views/OrdersView.vue index 093f71b..91e79f5 100644 --- a/frontend/src/views/OrdersView.vue +++ b/frontend/src/views/OrdersView.vue @@ -24,11 +24,12 @@ - + @@ -165,6 +166,15 @@ async function remove(row: any) { load() } +async function share(row: any) { + const d = await api('post', `/api/share-order/${row.id}`) + const url = `${location.origin}${d.url}` + await navigator.clipboard?.writeText(url).catch(() => undefined) + ElMessageBox.alert( + `分享链接已复制:
${url}

客户打开后可免登录查看订单摘要。`, + '分享订单', { dangerouslyUseHTMLString: true }) +} + // ---- 新建 ---- const createVisible = ref(false) const saving = ref(false) diff --git a/frontend/src/views/PrinterView.vue b/frontend/src/views/PrinterView.vue new file mode 100644 index 0000000..2a40d01 --- /dev/null +++ b/frontend/src/views/PrinterView.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/views/RenderView.vue b/frontend/src/views/RenderView.vue new file mode 100644 index 0000000..217d4aa --- /dev/null +++ b/frontend/src/views/RenderView.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/src/views/ShareOrderView.vue b/frontend/src/views/ShareOrderView.vue new file mode 100644 index 0000000..ab550bd --- /dev/null +++ b/frontend/src/views/ShareOrderView.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/src/huamenchuang/api/platform.py b/src/huamenchuang/api/platform.py new file mode 100644 index 0000000..600042b --- /dev/null +++ b/src/huamenchuang/api/platform.py @@ -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]) diff --git a/src/huamenchuang/api/router.py b/src/huamenchuang/api/router.py index 5452c4a..771ffef 100644 --- a/src/huamenchuang/api/router.py +++ b/src/huamenchuang/api/router.py @@ -8,6 +8,7 @@ from huamenchuang.api import ( customers, optimize, orders, + platform, quote, warehouse, windoor, @@ -23,3 +24,4 @@ api_router.include_router(optimize.router) api_router.include_router(quote.router) api_router.include_router(warehouse.router) api_router.include_router(business.router) +api_router.include_router(platform.router) diff --git a/src/huamenchuang/models/extend.py b/src/huamenchuang/models/extend.py new file mode 100644 index 0000000..2166c61 --- /dev/null +++ b/src/huamenchuang/models/extend.py @@ -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) diff --git a/src/huamenchuang/services/optimize_service.py b/src/huamenchuang/services/optimize_service.py index c5ca609..8f9e1e4 100644 --- a/src/huamenchuang/services/optimize_service.py +++ b/src/huamenchuang/services/optimize_service.py @@ -80,6 +80,7 @@ def optimize_1d(demands: list[BarDemand], stocks: list[StockBar], for b in avail if b["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) return { "plans": [ @@ -98,7 +99,8 @@ def optimize_1d(demands: list[BarDemand], stocks: list[StockBar], "stock_bars_used": len(plans), "total_demand_mm": total_demand, "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, }, }