feat: v1.0 AI炫图工作台上线+业务做实+按需裁剪+主题精致化
- AI炫图: 自研程序化场景合成引擎(风格×时间×场景, 家具剪影/光束/暗角, seed构图微差), 阶段化生成动画+四方案画廊+PNG下载, 引擎抽为共享模块 - 业务做实: 报价接入产品价格本(use_price_book), 注册邀请码+返利真实计数, 报价单转订单携带图纸 - 按需裁剪: 下线短信验证码登录/支付收银, 商城改为功能总览 - 主题精致化: 品牌色设计变量+Element Plus主题覆盖+视觉升级 - 全接口回归通过, 移除项405确认
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# CHANGELOG
|
||||
|
||||
- [2026-09-20 22:30] FEAT: v1.0 AI炫图上线+业务做实+按需裁剪+主题精致化。①AI炫图工作台(/ai_render): 自研程序化场景合成引擎(风格×时间×场景, 家具/绿植/光束/暗角, seed构图微差), 阶段化生成动画+四方案画廊+PNG下载, 渲染引擎抽为共享模块 variantRenderer; ②业务做实: 报价接入产品价格本(use_price_book按材料类别均价), 注册支持邀请码且邀请返利真实计数(User.invited_by), 报价单转订单携带图纸; ③按需裁剪: 下线短信验证码登录/支付收银接口与相关界面, 商城改为功能总览; ④主题精致化: 品牌色设计变量+Element Plus主题覆盖+卡片/登录页/滚动条视觉升级; 全接口回归通过(移除项405确认) (Files: src/huamenchuang/api/platform.py, src/huamenchuang/api/quote.py, src/huamenchuang/api/auth.py, src/huamenchuang/models/user.py, frontend/src/components/AIRenderCanvas.vue, frontend/src/components/variantRenderer.ts, frontend/src/views/AIRenderView.vue, frontend/src/views/LoginView.vue, frontend/src/views/MarketView.vue, frontend/src/views/QuotationsView.vue, frontend/src/App.vue, doc/API.md)
|
||||
- [2026-09-20 21:00] FIX+FEAT: v0.9 样板数据完整性验证与解析加固——①双源确认样板库完整性: search接口254条 = 画图编辑器for_ht目录内嵌254条, 0遗漏; ②全量体检发现6套样板含7个畸形段(缺ps/pe)导致解析报错, 前后端重放/算料均加固过滤(0异常/1046窗格); ③新增报表统计模块(/chart: 近N天订单数金额按日+8项总量+状态分布, 自绘SVG柱图); ④样板库官方/我的样板双Tab(is_default过滤); ⑤使用指南接入帮助文章接口(种子8篇自撰文章); ⑥seed脚本分类树幂等修复; 全接口回归18/18通过 (Files: src/huamenchuang/core/drawing.py, src/huamenchuang/api/reports.py, src/huamenchuang/api/windoor.py, src/huamenchuang/scripts/*, frontend/src/types/drawing.ts, frontend/src/views/ReportView.vue, frontend/src/views/TemplatesView.vue, frontend/src/views/GuideView.vue)
|
||||
- [2026-09-20 20:10] FEAT: v0.8 平台扩展——3D展示/效果图渲染/打印中心/下料方案/设备模块/订单分享/验证码登录。①Three.js 3D窗型(重放区域挤出玻璃+型材盒体, 自研轨道控制);②Canvas场景合成效果图(墙面/玻璃渐变/投影, 三场景, 设置持久化到公司参数);③A4打印中心(订单模板+进度+签字栏, 打印CSS隔离);④合同级下料方案(汇总订单算料→FFD→参数持久化, 修正利用率为已排入/已用料);⑤设备激活绑定(序列号+激活码);⑥订单分享(HMAC可逆token+公开只读页);⑦mock短信验证码登录全流程可用(接签名后换provider);⑧帮助文章接口。新增12组API, 前端+6页面共18页 (Files: src/huamenchuang/api/platform.py, src/huamenchuang/models/extend.py, frontend/src/components/WindoorThreed.vue, frontend/src/components/RenderCanvas.vue, frontend/src/views/*, doc/API.md)
|
||||
- [2026-09-20 19:00] FEAT: v0.7 全功能复刻与前端适配——新增产品价格本/报价单(BJ单号,手动报价+样板算料)/售后单(问题描述必填对齐原站)/量尺单(洞口尺寸清单)/阳光房订单(order_type=1)/个人中心(资料/密码/邀请返利 HMC邀请码) 模块;前端 15 页面(登录注册双Tab/商城/使用指南等)+响应式侧边栏(<900px折叠)+图标全局注册;订单新增order_type字段(含SQLite手工迁移) (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/business.py, src/huamenchuang/api/auth.py, frontend/src/views/*, frontend/src/router/index.ts, doc/API.md)
|
||||
|
||||
+14
-16
@@ -332,28 +332,26 @@
|
||||
|
||||
**响应式适配**:侧边栏 <900px 自动折叠为图标栏;<700px 主区边距收窄;表格列横向滚动。
|
||||
|
||||
### 12.14 平台扩展模块(v0.8,`/api`)
|
||||
### 12.14 平台扩展模块(v1.0,`/api`)
|
||||
|
||||
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| 发送验证码 | POST | `/api/verification-code-new` | `{phone_number, scene: login/reset/bind}` | **mock 短信通道**(验证码打服务端日志);接入短信签名后替换 provider 即可 |
|
||||
| 验证码登录 | POST | `/api/code-login` | `{phone_number, code}` | 校验通过自动注册并登录 |
|
||||
| 创建支付订单 | POST | `/api/pay-orders` | `{subject, amount, channel: wechat/alipay}` | 支付通道适配层:商户凭据就绪后接通收银台,当前记录待支付单 |
|
||||
| 支付单列表 | GET | `/api/pay-orders` | 分页 | |
|
||||
| 渲染设置 | GET/POST | `/api/rendering-info` | `{wall_color, scene, glass_tint}` | 存公司参数(对齐原站 company_param_value) |
|
||||
| **合同下料方案** | POST | `/api/cut_solution` | `{contract_id, kerf}` | 汇总合同下订单图纸的算料需求 → FFD 优化 → 参数持久化 |
|
||||
| 设备激活 | POST | `/api/device-activate` | `{sn, activate_code, name}` | 首绑自动生成激活码,防重复绑定(对齐 device-activate) |
|
||||
| 渲染设置 + AI 风格 | GET/POST | `/api/rendering-info` | `{wall_color, scene, glass_tint, ai_style, ai_time}` | 存公司参数;风格: modern/chinese/european/nordic,时间: day/dusk/night |
|
||||
| **合同下料方案** | POST | `/api/cut_solution` | `{contract_id, kerf}` | 汇总合同下订单图纸的算料需求 → FFD 优化 → 参数持久化;`GET /api/cut_solution/{id}` 读回 |
|
||||
| 设备激活 | POST | `/api/device-activate` | `{sn, activate_code, name}` | 首绑自动生成激活码,防重复绑定 |
|
||||
| 设备列表 | GET | `/api/devices` | — | |
|
||||
| 生成订单分享 | POST | `/api/share-order/{id}` | — | HMAC 可逆 token |
|
||||
| **公开订单摘要** | GET | `/api/shared-order/{token}` | 无需登录 | 分享页数据(对齐 customOrder-share) |
|
||||
| 帮助文章 | GET | `/api/articles?category=` | query | 对齐原站 articleList |
|
||||
| **公开订单摘要** | GET | `/api/shared-order/{token}` | 无需登录 | 分享页数据 |
|
||||
| 帮助文章 | GET | `/api/articles?category=` | query | 种子 8 篇自撰指南 |
|
||||
| 报表汇总 | GET | `/api/reports/summary?days=14` | days | 按日订单数/金额 + 8 项总量 + 状态分布 |
|
||||
|
||||
新增前端能力(v0.8):
|
||||
- **效果图渲染**(`/rendering`):自研 Canvas 场景合成(墙面色/玻璃渐变高光/投影,室内外影棚三场景),设置持久化
|
||||
- **3D 展示**(`/threed`):自研 Three.js 实现——分割重放区域挤出为玻璃板 + 型材盒体 + 地面网格,自实现轨道控制(旋转/缩放/平移)
|
||||
- **打印中心**(`/printer`):A4 生产订单模板(图纸缩略图+进度+签字栏),打印 CSS 隔离
|
||||
- **下料方案**(`/cut_solution`):合同级汇总下料
|
||||
- **设备管理**(`/devices`)、**订单分享**(`/share/order/:token` 公开页)、登录页**验证码登录** Tab(60s 倒计时)
|
||||
> v1.0 调整:按需求**移除短信验证码登录与支付收银接口**(verification-code-new / code-login / pay-orders 已下线);登录采用手机号+密码。
|
||||
|
||||
新增前端能力(v1.0):
|
||||
- **AI 炫图**(`/ai_render`):自研程序化场景合成引擎——风格(现代简约/新中式/欧式/北欧)× 时间(日景/黄昏/夜景)× 场景(室内/室外/影棚),阶段化生成动画,四方案画廊,一键下载 PNG
|
||||
- **效果图渲染**(`/rendering`)与 **3D 展示**(`/threed`,Three.js 挤出重建)
|
||||
- **打印中心**(`/printer`)、**下料方案**(`/cut_solution`)、**设备管理**(`/devices`)、**订单分享**(`/share/order/:token` 公开页)、**报表统计**(`/chart`)
|
||||
- **主题精致化**:品牌色设计变量、Element Plus 主题覆盖、卡片圆角阴影、登录页氛围渐变、响应式侧边栏
|
||||
|
||||
### 12.13 种子数据
|
||||
|
||||
|
||||
+37
-1
@@ -3,6 +3,42 @@
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* ===== 全局设计变量 ===== */
|
||||
:root {
|
||||
--brand: #3f5f80;
|
||||
--brand-2: #6b8cae;
|
||||
--accent: #5b3fd4;
|
||||
--danger: #e6483d;
|
||||
--radius: 10px;
|
||||
--shadow-card: 0 2px 12px rgba(30, 45, 65, 0.08);
|
||||
}
|
||||
/* Element Plus 主题覆盖 */
|
||||
:root {
|
||||
--el-color-primary: #3f5f80;
|
||||
--el-color-primary-light-3: #66819f;
|
||||
--el-color-primary-light-5: #8fa3ba;
|
||||
--el-color-primary-light-7: #b9c6d4;
|
||||
--el-color-primary-light-8: #ccd6e0;
|
||||
--el-color-primary-light-9: #e5ebf1;
|
||||
--el-color-primary-dark-2: #324c66;
|
||||
--el-border-radius-base: 8px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* 卡片统一圆角与悬停 */
|
||||
.el-card { border-radius: var(--radius); border: none; box-shadow: var(--shadow-card) !important; }
|
||||
.el-button { border-radius: 8px; }
|
||||
/* 表头底色 */
|
||||
.el-table th.el-table__cell { background: #f7f9fb; }
|
||||
/* 页面切换动效 */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity .18s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: #c9d2db; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a9b6c2; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<canvas ref="cvRef" class="ai-canvas" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** AI 炫图画布组件:薄封装,渲染引擎见 variantRenderer.ts */
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { renderAI } from './variantRenderer'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
drawingData: string | object
|
||||
style?: 'modern' | 'chinese' | 'european' | 'nordic'
|
||||
time?: 'day' | 'dusk' | 'night'
|
||||
scene?: 'indoor' | 'outdoor' | 'studio'
|
||||
seed?: number
|
||||
}>(), { style: 'modern', time: 'day', scene: 'indoor', seed: 1 })
|
||||
|
||||
const cvRef = ref<HTMLCanvasElement>()
|
||||
|
||||
function render() {
|
||||
if (cvRef.value && props.drawingData) renderAI(cvRef.value, props.drawingData, props.style, props.time, props.scene, props.seed)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
watch(() => [props.drawingData, props.style, props.time, props.scene, props.seed], render, { immediate: true })
|
||||
window.addEventListener('resize', render)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-canvas { width: 100%; height: 100%; display: block; border-radius: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* AI 炫图渲染引擎(自研程序化场景合成,纯函数)
|
||||
* renderAI(canvas, drawingData, style, time, scene, seed)
|
||||
* 墙面/地板/家具剪影/天空/玻璃光影/光束/暗角;seed 决定构图微差。
|
||||
*/
|
||||
import { bboxOf, parseDrawing, regionPoints, replayShape, type DrawingData } from '../types/drawing'
|
||||
|
||||
export interface Palette {
|
||||
wall: [string, string]; floor: [string, string]; sky: [string, string, string]
|
||||
frame: string; accent: string; light: string
|
||||
}
|
||||
|
||||
export const PRESETS: Record<string, Palette> = {
|
||||
modern: { wall: ['#f6f2ea', '#e7e0d4'], floor: ['#c9a97a', '#a98a5f'], sky: ['#bcd9f0', '#e8f2fa', '#f8fbff'], frame: '#3a3e43', accent: '#7a8b6f', light: '#fff7e0' },
|
||||
chinese: { wall: ['#f3e9d7', '#e5d5ba'], floor: ['#8a4a32', '#6d3826'], sky: ['#c2d8ea', '#e6eef5', '#f5f8fb'], frame: '#7a3b28', accent: '#9a6b4f', light: '#ffedc9' },
|
||||
european: { wall: ['#f7f1e3', '#ece0c8'], floor: ['#b08d5f', '#8f6f4a'], sky: ['#aecdea', '#dbe8f5', '#f2f7fd'], frame: '#e8e2d2', accent: '#c9b28a', light: '#fff3d6' },
|
||||
nordic: { wall: ['#f2f3f1', '#e2e5e1'], floor: ['#d9c6a5', '#bfa87f'], sky: ['#c5dcf2', '#e9f4fc', '#fafdff'], frame: '#f4f2ec', accent: '#8fae9c', light: '#fdf9ec' },
|
||||
}
|
||||
|
||||
const TIME_SKY: Record<string, Record<string, [string, string, string]>> = {
|
||||
day: {},
|
||||
dusk: { indoor: ['#f0a56a', '#f6c98f', '#fde8c8'], outdoor: ['#e88a4f', '#f4b878', '#fce4bd'], studio: ['#e8b585', '#f4d3a8', '#fdf0d8'] },
|
||||
night: { indoor: ['#1d2b45', '#2c3e5f', '#48597a'], outdoor: ['#152238', '#22344f', '#3a4d6e'], studio: ['#26303f', '#39465a', '#525f74'] },
|
||||
}
|
||||
|
||||
function rng(seed: number) {
|
||||
let s = seed % 2147483647
|
||||
if (s <= 0) s += 2147483646
|
||||
return () => (s = s * 16807 % 2147483647) / 2147483647
|
||||
}
|
||||
|
||||
export function renderAI(
|
||||
cv: HTMLCanvasElement, drawingData: string | object,
|
||||
style = 'modern', time = 'day', scene = 'indoor', seed = 1,
|
||||
): void {
|
||||
const wrap = cv.parentElement
|
||||
if (!wrap) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
cv.width = wrap.clientWidth * dpr
|
||||
cv.height = wrap.clientHeight * dpr
|
||||
const ctx = cv.getContext('2d')!
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
const W = wrap.clientWidth, H = wrap.clientHeight
|
||||
if (!W || !H) return
|
||||
|
||||
const pal = PRESETS[style] ?? PRESETS.modern
|
||||
const rand = rng(seed * 7919 + 13)
|
||||
|
||||
// 背景(天空)
|
||||
const sky = TIME_SKY[time]?.[scene] ?? pal.sky
|
||||
const bg = ctx.createLinearGradient(0, 0, 0, H)
|
||||
bg.addColorStop(0, sky[0]); bg.addColorStop(0.6, sky[1]); bg.addColorStop(1, sky[2])
|
||||
ctx.fillStyle = bg
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
// 墙面 + 地板
|
||||
const floorY = H * 0.68
|
||||
const wall = ctx.createLinearGradient(0, 0, 0, floorY)
|
||||
wall.addColorStop(0, pal.wall[0]); wall.addColorStop(1, pal.wall[1])
|
||||
ctx.fillStyle = wall
|
||||
ctx.fillRect(0, 0, W, floorY)
|
||||
const floor = ctx.createLinearGradient(0, floorY, 0, H)
|
||||
floor.addColorStop(0, pal.floor[0]); floor.addColorStop(1, pal.floor[1])
|
||||
ctx.fillStyle = floor
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, floorY); ctx.lineTo(W, floorY)
|
||||
ctx.lineTo(W * 0.92, H); ctx.lineTo(W * 0.08, H)
|
||||
ctx.closePath(); ctx.fill()
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.08)'
|
||||
ctx.lineWidth = 1
|
||||
for (let i = 1; i < 8; i++) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(W * (i / 8), floorY)
|
||||
ctx.lineTo(W * (0.08 + 0.84 * (i / 8)), H)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.10)'
|
||||
ctx.fillRect(0, floorY - 6, W, 6)
|
||||
|
||||
// 窗型几何
|
||||
let dd: DrawingData
|
||||
try { dd = parseDrawing(drawingData) } catch { return }
|
||||
if (!dd?.sm?.length) return
|
||||
const pts = dd.sm.flatMap(s => replayShape(s).regions.flat().flatMap(seg => [seg.ps, seg.pe]))
|
||||
if (!pts.length) return
|
||||
const gb = bboxOf(pts)
|
||||
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
|
||||
const scale = Math.min(W * 0.56 / gw, H * 0.52 / gh)
|
||||
const ox = (W - gw * scale) / 2
|
||||
const oy = floorY - gh * scale - H * 0.12
|
||||
const tx = (x: number) => ox + (x - gb.x0) * scale
|
||||
const ty = (y: number) => oy + (gb.y1 - y) * scale
|
||||
|
||||
// 窗洞
|
||||
ctx.save()
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.4)'
|
||||
ctx.shadowBlur = 30
|
||||
ctx.fillStyle = time === 'night' ? '#16233a' : sky[1]
|
||||
ctx.fillRect(tx(gb.x0) - 10, ty(gb.y1) - 10, gw * scale + 20, gh * scale + 20)
|
||||
ctx.restore()
|
||||
|
||||
// 玻璃
|
||||
for (const shape of dd.sm) {
|
||||
for (const region of replayShape(shape).regions) {
|
||||
const rpts = regionPoints(region)
|
||||
const bb = bboxOf(rpts)
|
||||
const gx = tx(bb.x0), gy = ty(bb.y1), w = (bb.x1 - bb.x0) * scale, h = (bb.y1 - bb.y0) * scale
|
||||
const g = ctx.createLinearGradient(gx, gy, gx, gy + h)
|
||||
g.addColorStop(0, sky[0]); g.addColorStop(0.55, sky[1]); g.addColorStop(1, sky[2])
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
rpts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
|
||||
ctx.closePath()
|
||||
ctx.clip()
|
||||
ctx.fillStyle = g
|
||||
ctx.fillRect(gx, gy, w, h)
|
||||
// 远景剪影
|
||||
ctx.fillStyle = time === 'night' ? 'rgba(10,18,32,0.55)' : 'rgba(90,110,130,0.28)'
|
||||
let x = gx
|
||||
while (x < gx + w) {
|
||||
const bw = 14 + rand() * 26, bh = (10 + rand() * 0.5 * h) * (time === 'night' ? 0.7 : 0.45)
|
||||
ctx.fillRect(x, gy + h - bh, bw, bh)
|
||||
x += bw + 4 + rand() * 10
|
||||
}
|
||||
// 光束
|
||||
if (time !== 'dusk') {
|
||||
const ray = ctx.createLinearGradient(gx, gy, gx + w * 0.7, gy + h)
|
||||
ray.addColorStop(0, time === 'day' ? 'rgba(255,244,200,0.5)' : 'rgba(120,160,255,0.25)')
|
||||
ray.addColorStop(1, 'rgba(255,255,255,0)')
|
||||
ctx.fillStyle = ray
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(gx, gy); ctx.lineTo(gx + w * 0.55, gy)
|
||||
ctx.lineTo(gx + w * 0.2, gy + h); ctx.lineTo(gx - w * 0.05, gy + h)
|
||||
ctx.closePath(); ctx.fill()
|
||||
}
|
||||
// 反光斜带
|
||||
ctx.globalAlpha = 0.14
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(gx + w * 0.12, gy); ctx.lineTo(gx + w * 0.3, gy)
|
||||
ctx.lineTo(gx - w * 0.05, gy + h); ctx.lineTo(gx - w * 0.22, gy + h)
|
||||
ctx.closePath(); ctx.fill()
|
||||
ctx.globalAlpha = 1
|
||||
ctx.restore()
|
||||
ctx.strokeStyle = pal.frame
|
||||
ctx.lineWidth = 2.4
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
// 外框
|
||||
ctx.strokeStyle = pal.frame
|
||||
ctx.lineWidth = 7
|
||||
ctx.strokeRect(tx(gb.x0) - 4, ty(gb.y1) - 4, gw * scale + 8, gh * scale + 8)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.35)'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.strokeRect(tx(gb.x0) - 1, ty(gb.y1) - 1, gw * scale + 2, gh * scale + 2)
|
||||
|
||||
// 地板光束
|
||||
if (time !== 'dusk') {
|
||||
const ray = ctx.createLinearGradient(tx(gb.x0), floorY, tx(gb.x0) + gw * scale * 0.4, H)
|
||||
ray.addColorStop(0, time === 'day' ? 'rgba(255,240,190,0.30)' : 'rgba(140,170,255,0.12)')
|
||||
ray.addColorStop(1, 'rgba(255,255,255,0)')
|
||||
ctx.fillStyle = ray
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(tx(gb.x0), oy); ctx.lineTo(tx(gb.x1), oy)
|
||||
ctx.lineTo(tx(gb.x1) + W * 0.12, H); ctx.lineTo(tx(gb.x0) - W * 0.05, H)
|
||||
ctx.closePath(); ctx.fill()
|
||||
}
|
||||
|
||||
// 家具剪影
|
||||
const sil = time === 'night' ? 'rgba(16,24,40,0.82)' : 'rgba(60,70,80,0.55)'
|
||||
ctx.fillStyle = sil
|
||||
const sy = floorY - H * 0.02
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(W * 0.06, sy - H * 0.10, W * 0.16, H * 0.10, 8)
|
||||
ctx.roundRect(W * 0.05, sy - H * 0.16, W * 0.035, H * 0.10, 6)
|
||||
ctx.fill()
|
||||
const px = W * 0.88, py = floorY + 4
|
||||
ctx.strokeStyle = 'rgba(70,60,45,0.7)'
|
||||
ctx.lineWidth = 4
|
||||
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, py - H * 0.05); ctx.stroke()
|
||||
ctx.fillStyle = pal.accent
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = -Math.PI / 2 + (i - 2.5) * 0.45 + rand() * 0.1
|
||||
const L = H * (0.07 + rand() * 0.045)
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(px + Math.cos(a) * L * 0.5, py - H * 0.05 + Math.sin(a) * L * 0.5, L * 0.34, L * 0.13, a, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
if (style === 'chinese') {
|
||||
ctx.fillStyle = 'rgba(150,60,45,0.75)'
|
||||
ctx.fillRect(W * 0.14, H * 0.06, W * 0.05, H * 0.2)
|
||||
ctx.strokeStyle = 'rgba(60,40,30,0.8)'; ctx.lineWidth = 3
|
||||
ctx.strokeRect(W * 0.14, H * 0.06, W * 0.05, H * 0.2)
|
||||
} else if (style === 'european') {
|
||||
ctx.fillStyle = 'rgba(214,196,158,0.85)'
|
||||
ctx.fillRect(W * 0.03, 0, W * 0.06, floorY * 0.9)
|
||||
ctx.fillRect(W * 0.91, 0, W * 0.06, floorY * 0.9)
|
||||
} else if (style === 'nordic') {
|
||||
ctx.fillStyle = 'rgba(200,214,206,0.6)'
|
||||
ctx.beginPath(); ctx.roundRect(W * 0.72, floorY - H * 0.08, W * 0.1, H * 0.08, 6); ctx.fill()
|
||||
}
|
||||
|
||||
// 暗角
|
||||
const vig = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.45, W / 2, H / 2, Math.max(W, H) * 0.75)
|
||||
vig.addColorStop(0, 'rgba(0,0,0,0)')
|
||||
vig.addColorStop(1, time === 'night' ? 'rgba(0,0,20,0.45)' : 'rgba(0,0,0,0.22)')
|
||||
ctx.fillStyle = vig
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const router = createRouter({
|
||||
{ path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } },
|
||||
{ path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
|
||||
{ path: 'rendering', component: () => import('../views/RenderView.vue'), meta: { title: '效果图渲染' } },
|
||||
{ path: 'ai_render', component: () => import('../views/AIRenderView.vue'), meta: { title: 'AI 炫图' } },
|
||||
{ path: 'threed', component: () => import('../views/RenderView.vue'), meta: { title: '3D 展示' } },
|
||||
{ path: 'printer', component: () => import('../views/PrinterView.vue'), meta: { title: '打印中心' } },
|
||||
{ path: 'cut_solution', component: () => import('../views/CutSolutionView.vue'), meta: { title: '下料方案' } },
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="head">
|
||||
<span class="brand">✦ AI 炫图</span>
|
||||
<span class="sub">选择样板 → 设定风格 → 一键生成多方案效果图</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="controls">
|
||||
<el-select v-model="templateId" filterable placeholder="选择样板" style="width:220px" @change="loadTemplate">
|
||||
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
|
||||
</el-select>
|
||||
<el-select v-model="form.ai_style" style="width:130px">
|
||||
<el-option value="modern" label="现代简约" /><el-option value="chinese" label="新中式" />
|
||||
<el-option value="european" label="欧式" /><el-option value="nordic" label="北欧" />
|
||||
</el-select>
|
||||
<el-select v-model="form.ai_time" style="width:110px">
|
||||
<el-option value="day" label="日景" /><el-option value="dusk" label="黄昏" /><el-option value="night" label="夜景" />
|
||||
</el-select>
|
||||
<el-select v-model="form.scene" style="width:110px">
|
||||
<el-option value="indoor" label="室内" /><el-option value="outdoor" label="室外" /><el-option value="studio" label="影棚" />
|
||||
</el-select>
|
||||
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generate">
|
||||
{{ generating ? '生成中…' : '✦ 生成 AI 炫图' }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
|
||||
:format="() => stageText" />
|
||||
|
||||
<div v-if="drawing && !variants.length && !generating" class="preview">
|
||||
<AIRenderCanvas :drawing-data="drawing" :style="form.ai_style" :time="form.ai_time"
|
||||
:scene="form.scene" :seed="form.seed" />
|
||||
</div>
|
||||
|
||||
<div v-if="variants.length" class="gallery">
|
||||
<div v-for="(v, i) in variants" :key="i" class="shot" :class="{ pick: pickIdx === i }" @click="pickIdx = i">
|
||||
<canvas :ref="el => setVariantRef(el, i)" class="shot-cv" />
|
||||
<div class="cap">{{ v.label }}</div>
|
||||
<el-icon class="dl" @click.stop="download(i)"><Download /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!drawing" description="请先选择样板" />
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import AIRenderCanvas from '../components/AIRenderCanvas.vue'
|
||||
|
||||
const templates = ref<any[]>([])
|
||||
const templateId = ref<number | undefined>()
|
||||
const drawing = ref('')
|
||||
const generating = ref(false)
|
||||
const progress = ref(0)
|
||||
const stageText = ref('')
|
||||
const variants = ref<{ label: string; style: string; time: string; seed: number }[]>([])
|
||||
const pickIdx = ref(0)
|
||||
const form = reactive({ ai_style: 'modern', ai_time: 'day', scene: 'indoor', seed: 1 })
|
||||
|
||||
const variantCvs: (HTMLCanvasElement | null)[] = []
|
||||
function setVariantRef(el: any, i: number) { variantCvs[i] = el as HTMLCanvasElement }
|
||||
|
||||
const STAGES = ['解析窗型结构', '合成场景光照', '匹配材质纹理', '生成效果图']
|
||||
|
||||
async function loadTemplate(tid: number | undefined) {
|
||||
drawing.value = ''
|
||||
variants.value = []
|
||||
if (!tid) return
|
||||
const t = await api<any>('get', `/api/windoor/templates/${tid}`)
|
||||
drawing.value = t.drawing_data
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
generating.value = true
|
||||
variants.value = []
|
||||
// 阶段化进度(本地程序化渲染,进度为演示动画)
|
||||
for (let s = 0; s < STAGES.length; s++) {
|
||||
stageText.value = STAGES[s]
|
||||
for (let p = 0; p <= 25; p += 5) {
|
||||
progress.value = Math.min(100, s * 25 + p)
|
||||
await new Promise(r => setTimeout(r, 24))
|
||||
}
|
||||
}
|
||||
progress.value = 100
|
||||
// 生成 4 个方案:风格×时间组合 + seed 微差
|
||||
variants.value = [
|
||||
{ label: `${styleName(form.ai_style)} · ${timeName(form.ai_time)}`, style: form.ai_style, time: form.ai_time, seed: form.seed },
|
||||
{ label: `${styleName(form.ai_style)} · 日景`, style: form.ai_style, time: 'day', seed: form.seed + 11 },
|
||||
{ label: `${styleName(form.ai_style)} · 黄昏`, style: form.ai_style, time: 'dusk', seed: form.seed + 23 },
|
||||
{ label: '新中式 · 夜景', style: 'chinese', time: 'night', seed: form.seed + 37 },
|
||||
]
|
||||
pickIdx.value = 0
|
||||
generating.value = false
|
||||
await nextFrames()
|
||||
// 渲染画廊各画布(共享渲染引擎)
|
||||
const { renderAI } = await import('../components/variantRenderer')
|
||||
variants.value.forEach((v, i) => {
|
||||
const cv = variantCvs[i]
|
||||
if (cv) renderAI(cv, drawing.value, v.style, v.time, form.scene, v.seed)
|
||||
})
|
||||
ElMessage.success('已生成 4 套效果图')
|
||||
}
|
||||
|
||||
function nextFrames(): Promise<void> {
|
||||
return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r())))
|
||||
}
|
||||
|
||||
function styleName(s: string) { return ({ modern: '现代简约', chinese: '新中式', european: '欧式', nordic: '北欧' } as Record<string, string>)[s] ?? s }
|
||||
function timeName(t: string) { return ({ day: '日景', dusk: '黄昏', night: '夜景' } as Record<string, string>)[t] ?? t }
|
||||
|
||||
function download(i: number) {
|
||||
const cv = variantCvs[i]
|
||||
if (!cv) return
|
||||
const a = document.createElement('a')
|
||||
a.href = cv.toDataURL('image/png')
|
||||
a.download = `AI炫图_${i + 1}.png`
|
||||
a.click()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
templates.value = await api<any>('get', '/api/windoor/templates?page=1&page_size=254')
|
||||
Object.assign(form, { ai_style: form.ai_style, ai_time: form.ai_time, scene: form.scene },
|
||||
await api<any>('get', '/api/rendering-info'))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.brand { font-size: 17px; font-weight: 700; color: #5b3fd4; }
|
||||
.sub { color: #909399; font-size: 12px; }
|
||||
.controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.preview { height: 58vh; }
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
|
||||
.shot { position: relative; border: 2px solid transparent; border-radius: 10px; overflow: hidden; cursor: pointer;
|
||||
height: 200px; transition: border-color .2s, transform .2s; }
|
||||
.shot:hover { transform: translateY(-2px); }
|
||||
.shot.pick { border-color: #5b3fd4; }
|
||||
.shot-cv { width: 100%; height: 100%; display: block; }
|
||||
.cap { position: absolute; left: 8px; bottom: 6px; color: #fff; font-size: 12px;
|
||||
background: rgba(0,0,0,0.45); padding: 2px 8px; border-radius: 10px; }
|
||||
.dl { position: absolute; right: 8px; top: 8px; color: #fff; background: rgba(0,0,0,0.45);
|
||||
border-radius: 6px; padding: 4px; cursor: pointer; }
|
||||
</style>
|
||||
@@ -19,6 +19,7 @@
|
||||
<el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item>
|
||||
<el-menu-item index="/cut_solution"><el-icon><SetUp /></el-icon>下料方案</el-menu-item>
|
||||
<el-menu-item index="/rendering"><el-icon><Picture /></el-icon>效果图渲染</el-menu-item>
|
||||
<el-menu-item index="/ai_render"><el-icon><MagicStick /></el-icon>AI 炫图</el-menu-item>
|
||||
<el-menu-item index="/threed"><el-icon><View /></el-icon>3D 展示</el-menu-item>
|
||||
<el-menu-item index="/printer"><el-icon><Printer /></el-icon>打印中心</el-menu-item>
|
||||
<el-menu-item index="/devices"><el-icon><Cpu /></el-icon>设备管理</el-menu-item>
|
||||
|
||||
@@ -7,47 +7,40 @@
|
||||
<el-tab-pane label="登录" name="login">
|
||||
<el-form :model="loginForm" @keyup.enter="doLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="loginForm.phone_number" placeholder="手机号" size="large" />
|
||||
<el-input v-model="loginForm.phone_number" placeholder="手机号" size="large">
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password />
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password>
|
||||
<template #prefix><el-icon><Lock /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">登 录</el-button>
|
||||
<el-button text type="primary" style="width:100%; margin: 8px 0 0" @click="tab = 'code'">验证码登录</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="验证码登录" name="code">
|
||||
<el-form :model="codeForm" @keyup.enter="doCodeLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="codeForm.phone_number" placeholder="手机号" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div style="display:flex; gap:8px; width:100%">
|
||||
<el-input v-model="codeForm.code" placeholder="验证码" size="large" style="flex:1" />
|
||||
<el-button size="large" :disabled="countdown > 0" @click="sendCode">
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doCodeLogin">登 录</el-button>
|
||||
<p class="sms-tip" v-if="mockCode">开发提示:mock 验证码 {{ mockCode }}(见服务端日志)</p>
|
||||
<el-button type="primary" size="large" class="w-full" :loading="loading" @click="doLogin">登 录</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="注册" name="register">
|
||||
<el-form :model="regForm" @keyup.enter="doRegister">
|
||||
<el-form-item>
|
||||
<el-input v-model="regForm.phone_number" placeholder="手机号" size="large" />
|
||||
<el-input v-model="regForm.phone_number" placeholder="手机号" size="large">
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="regForm.password" type="password" placeholder="设置密码(至少4位)" size="large" show-password />
|
||||
<el-input v-model="regForm.password" type="password" placeholder="设置密码(至少4位)" size="large" show-password>
|
||||
<template #prefix><el-icon><Lock /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="regForm.invite_code" placeholder="邀请码(可选)" size="large" />
|
||||
<el-input v-model="regForm.invite_code" placeholder="邀请码(可选,如 HMC000001)" size="large">
|
||||
<template #prefix><el-icon><Promotion /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doRegister">注 册</el-button>
|
||||
<el-button type="primary" size="large" class="w-full" :loading="loading" @click="doRegister">注 册</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<p class="tip">开发账号:13800000000 / 123456 | 忘记密码请联系管理员</p>
|
||||
<p class="tip">开发账号:13800000000 / 123456</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -65,33 +58,6 @@ const loading = ref(false)
|
||||
const tab = ref('login')
|
||||
const loginForm = reactive({ phone_number: '', password: '' })
|
||||
const regForm = reactive({ phone_number: '', password: '', invite_code: '' })
|
||||
const codeForm = reactive({ phone_number: '', code: '' })
|
||||
const countdown = ref(0)
|
||||
const mockCode = ref('')
|
||||
let cdTimer: number | undefined
|
||||
|
||||
async function sendCode() {
|
||||
if (!codeForm.phone_number) return ElMessage.warning('请输入手机号')
|
||||
await api('post', '/api/verification-code-new', { phone_number: codeForm.phone_number, scene: 'login' })
|
||||
ElMessage.success('验证码已发送(开发环境见服务端日志)')
|
||||
// 开发环境后端走 mock 通道:尝试从响应头拿不到就提示看日志
|
||||
mockCode.value = ''
|
||||
countdown.value = 60
|
||||
cdTimer = window.setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) clearInterval(cdTimer)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function doCodeLogin() {
|
||||
if (!codeForm.phone_number || !codeForm.code) return ElMessage.warning('请输入手机号和验证码')
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await api<{ token: string; user: { nickname: string } }>('post', '/api/code-login', codeForm)
|
||||
auth.setSession(d.token, d.user?.nickname ?? '')
|
||||
router.push('/dashboard')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
if (!loginForm.phone_number || !loginForm.password) return ElMessage.warning('请输入手机号和密码')
|
||||
@@ -111,6 +77,7 @@ async function doRegister() {
|
||||
try {
|
||||
const d = await api<{ token: string; user_id: number }>('post', '/api/auth/register', {
|
||||
phone_number: regForm.phone_number, password: regForm.password,
|
||||
invite_code: regForm.invite_code,
|
||||
})
|
||||
auth.setSession(d.token, regForm.phone_number.slice(-4))
|
||||
ElMessage.success('注册成功')
|
||||
@@ -123,9 +90,14 @@ async function doRegister() {
|
||||
|
||||
<style scoped>
|
||||
.login-page { height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #35506b 0%, #6b8cae 100%); }
|
||||
.login-card { width: 380px; text-align: center; padding: 12px 8px; }
|
||||
h2 { margin: 8px 0 4px; color: #303133; }
|
||||
.sub { color: #909399; font-size: 13px; margin: 0 0 12px; }
|
||||
background:
|
||||
radial-gradient(1200px 600px at 15% 10%, rgba(107,140,174,0.55), transparent),
|
||||
radial-gradient(1000px 500px at 85% 90%, rgba(91,63,212,0.35), transparent),
|
||||
linear-gradient(135deg, #2b3a4d 0%, #46617f 60%, #5b74a0 100%); }
|
||||
.login-card { width: 400px; padding: 16px 12px; border-radius: 14px;
|
||||
box-shadow: 0 20px 60px rgba(20, 35, 55, 0.45); }
|
||||
h2 { margin: 8px 0 4px; color: #303133; letter-spacing: 2px; }
|
||||
.sub { color: #909399; font-size: 13px; margin: 0 0 14px; }
|
||||
.tip { color: #c0c4cc; font-size: 12px; margin-top: 14px; }
|
||||
.w-full { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -4,34 +4,29 @@
|
||||
<el-card shadow="hover" class="module">
|
||||
<div class="m-head">
|
||||
<span class="m-name">{{ m.name }}</span>
|
||||
<el-tag size="small" :type="m.enabled ? 'success' : 'info'">{{ m.enabled ? '已开通' : '未开通' }}</el-tag>
|
||||
<el-tag size="small" type="success">已包含</el-tag>
|
||||
</div>
|
||||
<p class="m-desc">{{ m.desc }}</p>
|
||||
<div class="m-foot">
|
||||
<span class="m-price">{{ m.price > 0 ? `¥${m.price}/年` : '免费' }}</span>
|
||||
<el-button size="small" :type="m.enabled ? 'default' : 'primary'" :disabled="m.enabled"
|
||||
@click="buy(m)">{{ m.enabled ? '已开通' : '立即购买' }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-top:8px"
|
||||
title="以上功能在当前版本中全部开放使用;按需开通的订阅方案已预留(HMC_ENABLED_MODULES)。" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
|
||||
const modules = ref<any[]>([])
|
||||
|
||||
// 全部功能对演示账户开放(后端 HMC_ENABLED_MODULES 为准)
|
||||
const MODULE_DEFS = [
|
||||
{ key: 'order', name: '订单管理', desc: '订单全流程:报价下单、生产进度、图纸管理', price: 299 },
|
||||
{ key: 'quote', name: '智能报价', desc: '画图自动算料报价,型材/玻璃/五金明细', price: 199 },
|
||||
{ key: 'optimize', name: '优化下料', desc: '一维型材优化切割,提高利用率', price: 199 },
|
||||
{ key: 'template', name: '样板库', desc: '254 套官方窗型样板 + 自定义样板', price: 0 },
|
||||
{ key: 'warehouse', name: '仓库库存', desc: '材料出入库、库存预警', price: 99 },
|
||||
{ key: 'render', name: '效果图渲染', desc: '照片级渲染(规划中)', price: 399 },
|
||||
{ key: 'order', name: '订单管理', desc: '订单全流程:报价下单、生产进度、图纸管理、打印' },
|
||||
{ key: 'quote', name: '智能报价', desc: '画图自动算料报价,型材/玻璃/五金明细,接价格本' },
|
||||
{ key: 'optimize', name: '优化下料', desc: '一维型材优化切割(FFD),合同级下料方案' },
|
||||
{ key: 'template', name: '样板库', desc: '254 套官方窗型样板 + 自定义样板' },
|
||||
{ key: 'warehouse', name: '仓库库存', desc: '材料出入库、流水、库存预警' },
|
||||
{ key: 'render', name: '渲染工作台', desc: '效果图场景合成 + AI 炫图 + 3D 展示' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -42,12 +37,6 @@ onMounted(async () => {
|
||||
} catch { enabled = [] }
|
||||
modules.value = MODULE_DEFS.map(m => ({ ...m, enabled: enabled.includes(m.key) }))
|
||||
})
|
||||
|
||||
async function buy(m: any) {
|
||||
await ElMessageBox.confirm(
|
||||
`开通「${m.name}」需要 ¥${m.price}/年。演示环境未接入支付,可联系管理员在 HMC_ENABLED_MODULES 中开通。`,
|
||||
'购买', { type: 'info' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -55,6 +44,4 @@ async function buy(m: any) {
|
||||
.m-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.m-name { font-size: 16px; font-weight: 600; }
|
||||
.m-desc { color: #909399; font-size: 13px; min-height: 38px; margin: 10px 0; }
|
||||
.m-foot { display: flex; justify-content: space-between; align-items: center; }
|
||||
.m-price { color: #e6483d; font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -176,9 +176,11 @@ async function detail(row: any) {
|
||||
|
||||
async function toOrder(row: any) {
|
||||
await ElMessageBox.confirm(`将报价单 ${row.quote_no} 转为订单?`, '提示')
|
||||
const q = await api<any>('get', `/api/quotations/${row.id}`)
|
||||
await api('post', '/api/orders', { customer_id: row.customer_id, money: row.total,
|
||||
drawing_data: '', note: `来自报价单${row.quote_no}` })
|
||||
ElMessage.success('订单已创建')
|
||||
drawing_data: q.drawing_data || '',
|
||||
note: `来自报价单${row.quote_no}` })
|
||||
ElMessage.success('订单已创建(含图纸)')
|
||||
router.push('/orders')
|
||||
}
|
||||
|
||||
|
||||
@@ -28,14 +28,27 @@ async def login(body: LoginRequest, db: DbSession):
|
||||
}, message="登录成功")
|
||||
|
||||
|
||||
class RegisterBody(BaseModel):
|
||||
phone_number: str
|
||||
password: str
|
||||
invite_code: str = ""
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(body: LoginRequest, db: DbSession):
|
||||
"""注册并直接返回 token。"""
|
||||
async def register(body: RegisterBody, db: DbSession):
|
||||
"""注册(支持邀请码,真实记录邀请关系)。"""
|
||||
service = AuthService(db)
|
||||
try:
|
||||
user = await service.register(body.phone_number, body.password)
|
||||
except ValueError as e:
|
||||
return fail(str(e))
|
||||
if body.invite_code.startswith("HMC"):
|
||||
try:
|
||||
inviter_id = int(body.invite_code[3:])
|
||||
if inviter_id != user.id:
|
||||
user.invited_by = inviter_id
|
||||
except ValueError:
|
||||
pass
|
||||
from huamenchuang.core.security import create_token
|
||||
return ok(data={"token": create_token(user.id), "user_id": user.id}, message="注册成功")
|
||||
|
||||
@@ -86,11 +99,16 @@ async def change_password(body: PasswordBody, user: CurrentUser, db: DbSession):
|
||||
|
||||
|
||||
@router.get("/invitation")
|
||||
async def invitation_info(user: CurrentUser):
|
||||
"""邀请信息(对齐原站 personal-center/invitation 返利)。"""
|
||||
async def invitation_info(user: CurrentUser, db: DbSession):
|
||||
"""邀请信息(真实计数:被邀请人 = invited_by 指向当前用户的账户)。"""
|
||||
from sqlalchemy import func, select
|
||||
from huamenchuang.models.user import User
|
||||
invited = (await db.execute(
|
||||
select(func.count()).select_from(User).where(User.invited_by == user.id)
|
||||
)).scalar_one()
|
||||
return ok(data={
|
||||
"invite_code": f"HMC{user.id:06d}",
|
||||
"invited_count": 0,
|
||||
"bounty": "0.00",
|
||||
"rule": "好友通过邀请码注册并购买后,可获得相应奖励",
|
||||
"invited_count": invited,
|
||||
"bounty": f"{invited * 20:.2f}",
|
||||
"rule": "好友通过邀请码注册后,每位奖励 ¥20(购买后可抵扣)",
|
||||
})
|
||||
|
||||
@@ -1,133 +1,47 @@
|
||||
"""平台扩展路由:验证码登录、支付、渲染设置、下料方案、设备、分享、文章。"""
|
||||
"""平台扩展路由:渲染设置、下料方案、设备、分享、文章、AI 炫图风格配置。"""
|
||||
import datetime
|
||||
import random
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy import desc, select
|
||||
|
||||
from huamenchuang.core.database import SessionLocal
|
||||
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||
from huamenchuang.core.response import fail, ok, paginated
|
||||
from huamenchuang.models.business import Quotation
|
||||
from huamenchuang.core.response import fail, ok
|
||||
from huamenchuang.models.contract import Contract
|
||||
from huamenchuang.models.extend import Article, CompanyParam, Device, PayOrder, SmsCode
|
||||
from huamenchuang.models.extend import Article, CompanyParam, Device
|
||||
from huamenchuang.models.order import Order
|
||||
from huamenchuang.services.quote_service import quote as calc_quote
|
||||
|
||||
router = APIRouter(tags=["平台扩展"])
|
||||
|
||||
# ---------------- 短信验证码(provider 适配层) ----------------
|
||||
|
||||
async def _issue_code(db, phone: str, scene: str) -> str:
|
||||
"""签发验证码。未接入短信签名时走 mock 通道(控制台输出,响应不回传码)。"""
|
||||
code = f"{random.randint(0, 999999):06d}"
|
||||
db.add(SmsCode(phone_number=phone, code=code, scene=scene))
|
||||
print(f"[SMS:mock] {phone} 的{scene}验证码: {code}") # 接入真实短信后替换为服务商调用
|
||||
return code
|
||||
|
||||
|
||||
class CodeBody(BaseModel):
|
||||
phone_number: str
|
||||
scene: str = Field(default="login", pattern="^(login|reset|bind)$")
|
||||
|
||||
|
||||
@router.post("/verification-code-new")
|
||||
async def send_code(body: CodeBody, db: DbSession):
|
||||
"""发送短信验证码(mock 通道,见服务端日志)。"""
|
||||
await _issue_code(db, body.phone_number, body.scene)
|
||||
return ok(message="验证码已发送")
|
||||
|
||||
|
||||
class CodeLoginBody(BaseModel):
|
||||
phone_number: str
|
||||
code: str
|
||||
|
||||
|
||||
@router.post("/code-login")
|
||||
async def code_login(body: CodeLoginBody, db: DbSession):
|
||||
"""验证码登录:校验验证码后直接登录/注册。"""
|
||||
stmt = (select(SmsCode).where(SmsCode.phone_number == body.phone_number, SmsCode.used == 0,
|
||||
SmsCode.scene == "login").order_by(desc(SmsCode.id)).limit(1))
|
||||
record = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if record is None or record.code != body.code:
|
||||
return fail("验证码错误或已过期")
|
||||
record.used = 1
|
||||
from huamenchuang.core.security import create_token
|
||||
from huamenchuang.models.user import User
|
||||
from huamenchuang.services.auth_service import hash_password
|
||||
u = (await db.execute(select(User).where(User.phone_number == body.phone_number))).scalar_one_or_none()
|
||||
if u is None:
|
||||
u = User(phone_number=body.phone_number, password_hash=hash_password(body.code),
|
||||
nickname=f"用户{body.phone_number[-4:]}")
|
||||
db.add(u)
|
||||
await db.flush()
|
||||
return ok(data={"token": create_token(u.id), "user": {"id": u.id, "nickname": u.nickname}},
|
||||
message="登录成功")
|
||||
|
||||
|
||||
# ---------------- 支付订单(通道适配层) ----------------
|
||||
|
||||
class PayBody(BaseModel):
|
||||
subject: str
|
||||
amount: float = Field(gt=0)
|
||||
channel: str = Field(default="wechat", pattern="^(wechat|alipay)$")
|
||||
|
||||
|
||||
@router.post("/pay-orders")
|
||||
async def create_pay_order(body: PayBody, user: CurrentUser, db: DbSession):
|
||||
"""创建支付订单。微信/支付宝商户凭据配置后接通真实下单;当前返回待支付单。"""
|
||||
today = datetime.date.today().strftime("%Y%m%d")
|
||||
seq = random.randint(0, 9999)
|
||||
po = PayOrder(company_id=user.company_id, user_id=user.id, subject=body.subject[:128],
|
||||
amount=body.amount, channel=body.channel, pay_no=f"PAY{today}{seq:04d}")
|
||||
db.add(po)
|
||||
await db.flush()
|
||||
return ok(data={
|
||||
"pay_no": po.pay_no, "amount": float(po.amount), "channel": po.channel,
|
||||
"status": po.status,
|
||||
"pay_url": "", # 微信/支付宝下单接口返回的支付链接,凭据就绪后填充
|
||||
"tip": "支付通道未配置商户凭据,下单已记录,接通后自动唤起收银台",
|
||||
}, message="支付订单已创建")
|
||||
|
||||
|
||||
@router.get("/pay-orders")
|
||||
async def list_pay_orders(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15):
|
||||
conds = [PayOrder.company_id == user.company_id]
|
||||
total = (await db.execute(select(func.count()).select_from(PayOrder).where(*conds))).scalar_one()
|
||||
stmt = (select(PayOrder).where(*conds).order_by(desc(PayOrder.id))
|
||||
.offset((page - 1) * page_size).limit(page_size))
|
||||
items = list((await db.execute(stmt)).scalars())
|
||||
return ok(data=paginated([{
|
||||
"pay_no": p.pay_no, "subject": p.subject, "amount": float(p.amount),
|
||||
"channel": p.channel, "status": p.status, "created_at": str(p.created_at),
|
||||
} for p in items], page, page_size, total))
|
||||
|
||||
|
||||
# ---------------- 渲染设置 + 简易渲染参数 ----------------
|
||||
# ---------------- 渲染设置 + AI 炫图风格配置 ----------------
|
||||
|
||||
class RenderSettingBody(BaseModel):
|
||||
wall_color: str = "#f5f0e8"
|
||||
scene: str = Field(default="indoor", pattern="^(indoor|outdoor|studio)$")
|
||||
glass_tint: str = Field(default="#cfe3ee", pattern="^#[0-9a-fA-F]{6}$")
|
||||
ai_style: str = Field(default="modern", pattern="^(modern|chinese|european|nordic)$")
|
||||
ai_time: str = Field(default="day", pattern="^(day|dusk|night)$")
|
||||
|
||||
|
||||
async def _get_param(db, company_id: int, key: str):
|
||||
stmt = select(CompanyParam).where(CompanyParam.company_id == company_id, CompanyParam.key == key)
|
||||
return (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
@router.get("/rendering-info")
|
||||
async def get_render_setting(user: CurrentUser, db: DbSession):
|
||||
stmt = select(CompanyParam).where(CompanyParam.company_id == user.company_id,
|
||||
CompanyParam.key == "render_setting")
|
||||
p = (await db.execute(stmt)).scalar_one_or_none()
|
||||
import json as _json
|
||||
return ok(data=_json.loads(p.value) if p else {
|
||||
"wall_color": "#f5f0e8", "scene": "indoor", "glass_tint": "#cfe3ee"})
|
||||
p = await _get_param(db, user.company_id, "render_setting")
|
||||
defaults = {"wall_color": "#f5f0e8", "scene": "indoor", "glass_tint": "#cfe3ee",
|
||||
"ai_style": "modern", "ai_time": "day"}
|
||||
return ok(data={**defaults, **(_json.loads(p.value) if p else {})})
|
||||
|
||||
|
||||
@router.post("/rendering-info")
|
||||
async def save_render_setting(body: RenderSettingBody, user: CurrentUser, db: DbSession):
|
||||
import json as _json
|
||||
stmt = select(CompanyParam).where(CompanyParam.company_id == user.company_id,
|
||||
CompanyParam.key == "render_setting")
|
||||
p = (await db.execute(stmt)).scalar_one_or_none()
|
||||
p = await _get_param(db, user.company_id, "render_setting")
|
||||
if p is None:
|
||||
p = CompanyParam(company_id=user.company_id, key="render_setting")
|
||||
db.add(p)
|
||||
@@ -176,6 +90,16 @@ async def cut_solution(body: CutSolutionBody, user: CurrentUser, db: DbSession):
|
||||
message="下料方案已生成")
|
||||
|
||||
|
||||
@router.get("/cut_solution/{contract_id}")
|
||||
async def get_cut_solution(contract_id: int, user: CurrentUser, db: DbSession):
|
||||
"""读取已保存的下料方案参数。"""
|
||||
p = await _get_param(db, user.company_id, f"cut_solution_{contract_id}")
|
||||
if p is None:
|
||||
return fail("尚未生成下料方案")
|
||||
import json as _json
|
||||
return ok(data=_json.loads(p.value))
|
||||
|
||||
|
||||
async def _save_param(db, company_id: int, key: str, value: str) -> None:
|
||||
stmt = select(CompanyParam).where(CompanyParam.company_id == company_id, CompanyParam.key == key)
|
||||
p = (await db.execute(stmt)).scalar_one_or_none()
|
||||
@@ -239,19 +163,16 @@ def make_share_token(order_id: int) -> str:
|
||||
|
||||
|
||||
def parse_share_token(token: str) -> int | None:
|
||||
import base64
|
||||
import base64, hmac as _hmac
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)).decode()
|
||||
oid_s, sig = raw.split(".", 1)
|
||||
oid = int(oid_s)
|
||||
return oid if hmac.compare_digest(sig, _sign(oid)) else None
|
||||
return oid if _hmac.compare_digest(sig, _sign(oid)) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
import hmac # noqa: E402
|
||||
|
||||
|
||||
@router.get("/shared-order/{token}")
|
||||
async def shared_order(token: str, db: DbSession):
|
||||
"""通过分享 token 公开只读订单摘要(无需登录)。"""
|
||||
|
||||
@@ -15,6 +15,7 @@ class QuoteRequest(BaseModel):
|
||||
template_id: int | None = Field(default=None, description="或直接指定样板")
|
||||
order_id: int | None = Field(default=None, description="或对已有订单报价")
|
||||
prices: dict | None = Field(default=None, description="价格本覆盖 {frame,mullion,sash,glass,hardware}")
|
||||
use_price_book: bool = Field(default=False, description="从产品管理价格本取价(按材料类别均价)")
|
||||
|
||||
|
||||
async def _resolve_drawing(db, body: QuoteRequest) -> str | dict | None:
|
||||
@@ -32,14 +33,38 @@ async def _resolve_drawing(db, body: QuoteRequest) -> str | dict | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _price_book(db, company_id: int) -> dict | None:
|
||||
"""从产品价格本推导报价参数:各类材料均价。"""
|
||||
from sqlalchemy import func, select
|
||||
from huamenchuang.models.business import Product
|
||||
rows = (await db.execute(
|
||||
select(Product.material, func.avg(Product.price))
|
||||
.where(Product.company_id == company_id, Product.enabled == 1)
|
||||
.group_by(Product.material))).all()
|
||||
if not rows:
|
||||
return None
|
||||
avg = {m: float(p or 0) for m, p in rows}
|
||||
prices: dict = {}
|
||||
if avg.get("profile"):
|
||||
prices["frame"] = prices["mullion"] = prices["sash"] = round(avg["profile"], 2)
|
||||
if avg.get("glass"):
|
||||
prices["glass"] = round(avg["glass"], 2)
|
||||
if avg.get("hardware"):
|
||||
prices["hardware"] = round(avg["hardware"], 2)
|
||||
return prices or None
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def offer_price(body: QuoteRequest, user: CurrentUser, db: DbSession):
|
||||
"""算料报价:型材清单 + 玻璃面积 + 五金 + 总价。"""
|
||||
drawing = await _resolve_drawing(db, body)
|
||||
if drawing is None:
|
||||
return fail("未找到图纸数据")
|
||||
prices = body.prices
|
||||
if prices is None and body.use_price_book:
|
||||
prices = await _price_book(db, user.company_id)
|
||||
try:
|
||||
result = calc_quote(drawing, body.prices)
|
||||
result = calc_quote(drawing, prices)
|
||||
except Exception:
|
||||
return fail("图纸数据解析失败")
|
||||
return ok(data=result, message="报价成功")
|
||||
|
||||
@@ -19,6 +19,7 @@ class User(Base):
|
||||
language: Mapped[str] = mapped_column(String(16), default="zh-CN")
|
||||
# 套餐到期时间,空=试用
|
||||
try_expiration_time: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
invited_by: Mapped[int] = mapped_column(default=0, index=True) # 邀请人用户ID
|
||||
note: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
|
||||
Reference in New Issue
Block a user