feat: v0.4 画布编辑器——拖拽布置分格v1
- WindoorCanvas editable模式: 加竖梃/横梃点击玻璃区域实时分割
- 多边形两次交叉点法分割, mm.sp分割线记录同步, 撤销栈
- 订单详情: 编辑分格后保存图纸(PUT orders/{id})
- 样板库: 另存为我的样板(POST windoor/templates, 企业隔离)
- 10个API端点回归全部通过
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
- [2026-09-20 09:04] FEAT: v0.4 画布编辑器(拖拽布置v1)——WindoorCanvas 增加 editable 模式:+竖梃/+横梃点击玻璃区域实时分割(两次交叉点法多边形重算+mm.sp分割线同步)、撤销栈;订单详情可保存图纸(PUT orders),样板库可另存我的样板(新增 POST windoor/templates is_default=0);10接口回归全通过 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/views/*, src/huamenchuang/api/windoor.py, src/huamenchuang/schemas/windoor.py, doc/API.md)
|
||||||
- [2026-09-20 09:35] FEAT: v0.3 报价算料+仓库库存+前端扩展——报价引擎(从drawing_data几何提取外框/中梃/扇框型材清单+玻璃鞋带面积,套价格本计价);仓库模块(材料CRUD/出入库/流水/负库存拦截/预警);前端新增仓库页与报价弹窗(样板库/订单详情可唤起);SQLite路径锚定项目根修复cwd问题;全接口回归9/9通过 (Files: src/huamenchuang/**, frontend/**, doc/API.md)
|
- [2026-09-20 09:35] FEAT: v0.3 报价算料+仓库库存+前端扩展——报价引擎(从drawing_data几何提取外框/中梃/扇框型材清单+玻璃鞋带面积,套价格本计价);仓库模块(材料CRUD/出入库/流水/负库存拦截/预警);前端新增仓库页与报价弹窗(样板库/订单详情可唤起);SQLite路径锚定项目根修复cwd问题;全接口回归9/9通过 (Files: src/huamenchuang/**, frontend/**, doc/API.md)
|
||||||
- [2026-09-20 09:05] FEAT: 前后端全量开发 v0.2——后端新增客户/合同(意向单→正式)/优化下料(FFD)模块与订单合同快照;前端 Vue3+Element Plus 七页面(登录/工作台/订单/合同/客户/样板库/优化下料)与自研二维窗型画布渲染器(缩放/平移/开向标记/尺寸标注);构建产物挂载后端+SPA history 回退;全接口回归 6/6 通过 (Files: src/huamenchuang/**, frontend/**, README.md, doc/API.md)
|
- [2026-09-20 09:05] FEAT: 前后端全量开发 v0.2——后端新增客户/合同(意向单→正式)/优化下料(FFD)模块与订单合同快照;前端 Vue3+Element Plus 七页面(登录/工作台/订单/合同/客户/样板库/优化下料)与自研二维窗型画布渲染器(缩放/平移/开向标记/尺寸标注);构建产物挂载后端+SPA history 回退;全接口回归 6/6 通过 (Files: src/huamenchuang/**, frontend/**, README.md, doc/API.md)
|
||||||
- [2026-09-20 08:14] FEAT: 订单模块 v0.1 实现与二维平面图全量采集——FastAPI 分层架构(api/services/repositories/models)、订单状态机(意向单→正式单→生产中→完工/取消)、7节点生产进度、审计日志、JWT认证、样板库API;创建契约对齐目标站{contract_id,drawing_data,pic_url};254张样板平面图全部下载成功并静态挂载;端到端冒烟测试7/7通过 (Files: src/huamenchuang/**, pyproject.toml, README.md, doc/API.md, data/windoor-images/*, data/fetch-images.py)
|
- [2026-09-20 08:14] FEAT: 订单模块 v0.1 实现与二维平面图全量采集——FastAPI 分层架构(api/services/repositories/models)、订单状态机(意向单→正式单→生产中→完工/取消)、7节点生产进度、审计日志、JWT认证、样板库API;创建契约对齐目标站{contract_id,drawing_data,pic_url};254张样板平面图全部下载成功并静态挂载;端到端冒烟测试7/7通过 (Files: src/huamenchuang/**, pyproject.toml, README.md, doc/API.md, data/windoor-images/*, data/fetch-images.py)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
| 用户自存窗型列表 | GET | `/api/windoor/` | `page` | ⏳(需服务端排序参数,待抓包) |
|
| 用户自存窗型列表 | GET | `/api/windoor/` | `page` | ⏳(需服务端排序参数,待抓包) |
|
||||||
| 用户窗型详情 | GET | `/api/windoor/{id}` | 路径 `id` | ⏳(同 drawing_data 结构) |
|
| 用户窗型详情 | GET | `/api/windoor/{id}` | 路径 `id` | ⏳(同 drawing_data 结构) |
|
||||||
| 模板排序 | POST | `/api/windoor_template_sort` | `{ids:[...]}` | ⏳ |
|
| 模板排序 | POST | `/api/windoor_template_sort` | `{ids:[...]}` | ⏳ |
|
||||||
|
| **保存自定义样板** | POST | `/api/windoor/templates` | `{name, drawing_data, catelog_id?, note?}` | ✅ v0.4 新增:编辑器"另存为我的样板"(is_default=0,企业隔离) |
|
||||||
|
|
||||||
`drawing_data` 结构参见《克隆实施方案.md》§2.2。
|
`drawing_data` 结构参见《克隆实施方案.md》§2.2。
|
||||||
|
|
||||||
@@ -288,6 +289,7 @@
|
|||||||
| `/optimize` | 优化下料 | 需求/原料编辑 + FFD 计算结果(利用率/切割组合表) |
|
| `/optimize` | 优化下料 | 需求/原料编辑 + FFD 计算结果(利用率/切割组合表) |
|
||||||
|
|
||||||
**画布渲染器**(`components/WindoorCanvas.vue`,自研 Canvas2D):解析 drawing_data → Frame 玻璃区域多边形填充/描边 → 外框加粗 → 扇开向三角标记(od 角度) → 总宽高尺寸标注;支持滚轮缩放、拖拽平移、双击复位。
|
**画布渲染器**(`components/WindoorCanvas.vue`,自研 Canvas2D):解析 drawing_data → Frame 玻璃区域多边形填充/描边 → 外框加粗 → 扇开向三角标记(od 角度) → 总宽高尺寸标注;支持滚轮缩放、拖拽平移、双击复位。
|
||||||
|
**画布编辑器**(v0.4,`editable` 模式):+竖梃/+横梃工具 → 点击玻璃区域完成分割(多边形两次交叉点法实时重算区域,mm.sp 同步追加分割线记录),支持撤销;订单详情可"保存图纸"(PUT orders/{id}),样板库可"另存为我的样板"(POST windoor/templates)。
|
||||||
**报价弹窗**(`components/QuoteDialog.vue`):明细表 + 总价,可从样板库/订单详情唤起,支持浏览器打印。
|
**报价弹窗**(`components/QuoteDialog.vue`):明细表 + 总价,可从样板库/订单详情唤起,支持浏览器打印。
|
||||||
|
|
||||||
### 12.11 种子数据
|
### 12.11 种子数据
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="canvas-wrap" ref="wrapRef">
|
<div class="canvas-wrap" ref="wrapRef">
|
||||||
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
|
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
|
||||||
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset" />
|
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset" @click="onClick" />
|
||||||
<div class="hud">
|
<div class="hud">
|
||||||
<span v-if="info">{{ info }}</span>
|
<span v-if="info">{{ info }}</span>
|
||||||
|
<template v-if="editable">
|
||||||
|
<el-button size="small" :type="tool === 'v' ? 'primary' : ''" @click="arm('v')">+竖梃</el-button>
|
||||||
|
<el-button size="small" :type="tool === 'h' ? 'primary' : ''" @click="arm('h')">+横梃</el-button>
|
||||||
|
<el-button size="small" :disabled="!undoStack.length" @click="undo">撤销</el-button>
|
||||||
|
</template>
|
||||||
<el-button size="small" text @click="reset">重置视图</el-button>
|
<el-button size="small" text @click="reset">重置视图</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -11,25 +16,38 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 二维窗型画布渲染器(自研实现)
|
* 二维窗型画布(自研实现)
|
||||||
* 读取 shapeManager 序列化的 drawing_data:Frame→玻璃区域多边形 + 扇开向 + 尺寸标注
|
* 查看模式:渲染玻璃区域/外框/扇开向/尺寸标注,滚轮缩放、拖拽平移、双击复位
|
||||||
* 支持滚轮缩放、拖拽平移、双击复位
|
* 编辑模式(传入 editable):点击区域添加竖梃/横梃(多边形实时分割),支持撤销;
|
||||||
|
* 通过 expose 的 exportDrawing() 取回修改后的 drawing_data
|
||||||
*/
|
*/
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
bboxOf, parseDrawing, regionPoints,
|
bboxOf, parseDrawing, regionPoints,
|
||||||
type DrawingData, type FrameShape, type Pt, type SashShape,
|
type DrawingData, type FrameShape, type Pt, type Segment, type SashShape,
|
||||||
} from '../types/drawing'
|
} from '../types/drawing'
|
||||||
|
|
||||||
const props = defineProps<{ drawingData: string | object }>()
|
const props = defineProps<{ drawingData: string | object; editable?: boolean }>()
|
||||||
const cvRef = ref<HTMLCanvasElement>()
|
const cvRef = ref<HTMLCanvasElement>()
|
||||||
const wrapRef = ref<HTMLDivElement>()
|
const wrapRef = ref<HTMLDivElement>()
|
||||||
const info = ref('')
|
const info = ref('')
|
||||||
|
const emit = defineEmits<{ (e: 'change'): void }>()
|
||||||
|
|
||||||
let view = { scale: 1, cx: 0, cy: 0 } // cx/cy=模型坐标中心
|
let view = { scale: 1, cx: 0, cy: 0 }
|
||||||
let drag: { x: number; y: number } | null = null
|
let drag: { x: number; y: number } | null = null
|
||||||
let dd: DrawingData | null = null
|
let dd: DrawingData | null = null
|
||||||
let modelBox = { x0: 0, y0: 0, x1: 100, y1: 100 }
|
let modelBox = { x0: 0, y0: 0, x1: 100, y1: 100 }
|
||||||
|
const undoStack: string[] = []
|
||||||
|
const tool = ref<'' | 'v' | 'h'>('')
|
||||||
|
|
||||||
|
// 几何转换缓存(render 时更新,点击命中用)
|
||||||
|
let lastTransform = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
|
||||||
|
|
||||||
|
function toModel(sx: number, sy: number): Pt {
|
||||||
|
const { scale, midX, midY, W, H } = lastTransform
|
||||||
|
return { x: (sx - W / 2 - view.cx) / scale + midX, y: (H / 2 + view.cy - sy) / scale + midY }
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const cv = cvRef.value, wrap = wrapRef.value
|
const cv = cvRef.value, wrap = wrapRef.value
|
||||||
@@ -43,22 +61,20 @@ function render() {
|
|||||||
if (!dd) return
|
if (!dd) return
|
||||||
|
|
||||||
const W = wrap.clientWidth, H = wrap.clientHeight
|
const W = wrap.clientWidth, H = wrap.clientHeight
|
||||||
// 自适应:模型坐标 → 屏幕(Y 轴翻转)
|
|
||||||
const pad = 48
|
const pad = 48
|
||||||
const bw = modelBox.x1 - modelBox.x0, bh = modelBox.y1 - modelBox.y0
|
const bw = modelBox.x1 - modelBox.x0, bh = modelBox.y1 - modelBox.y0
|
||||||
const fit = Math.min((W - pad * 2) / (bw || 1), (H - pad * 2) / (bh || 1))
|
const fit = Math.min((W - pad * 2) / (bw || 1), (H - pad * 2) / (bh || 1))
|
||||||
const scale = fit * view.scale
|
const scale = fit * view.scale
|
||||||
const midX = (modelBox.x0 + modelBox.x1) / 2, midY = (modelBox.y0 + modelBox.y1) / 2
|
const midX = (modelBox.x0 + modelBox.x1) / 2, midY = (modelBox.y0 + modelBox.y1) / 2
|
||||||
|
lastTransform = { scale, midX, midY, W, H }
|
||||||
const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx
|
const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx
|
||||||
const ty = (y: number) => H / 2 - (y - midY) * scale + view.cy
|
const ty = (y: number) => H / 2 - (y - midY) * scale + view.cy
|
||||||
|
|
||||||
// 全局样式
|
|
||||||
const glass = '#d3ddda', glassAlpha = 'rgba(211,221,218,0.6)'
|
const glass = '#d3ddda', glassAlpha = 'rgba(211,221,218,0.6)'
|
||||||
const profile = '#5c6166'
|
const profile = '#5c6166'
|
||||||
ctx.lineJoin = 'round'
|
ctx.lineJoin = 'round'
|
||||||
|
|
||||||
for (const shape of dd.sm) drawFrame(ctx, shape, tx, ty, { glass, glassAlpha, profile })
|
for (const shape of dd.sm) drawFrame(ctx, shape, tx, ty, { glass, glassAlpha, profile })
|
||||||
|
|
||||||
info.value = dd.sm.length > 1 ? `窗型 · ${dd.sm.length} 个窗体组合` : '窗型'
|
info.value = dd.sm.length > 1 ? `窗型 · ${dd.sm.length} 个窗体组合` : '窗型'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +87,6 @@ function drawFrame(
|
|||||||
const regions = regionsRaw.map(regionPoints).filter(pts => pts.length >= 3)
|
const regions = regionsRaw.map(regionPoints).filter(pts => pts.length >= 3)
|
||||||
const cm = shape.cm ?? {}
|
const cm = shape.cm ?? {}
|
||||||
|
|
||||||
// ---- 玻璃区域 ----
|
|
||||||
for (const pts of regions) {
|
for (const pts of regions) {
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
pts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
|
pts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
|
||||||
@@ -81,16 +96,13 @@ function drawFrame(
|
|||||||
ctx.strokeStyle = cm.bn ?? color.profile
|
ctx.strokeStyle = cm.bn ?? color.profile
|
||||||
ctx.lineWidth = 1.6
|
ctx.lineWidth = 1.6
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
// 内描边示意型材厚度
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.65)'
|
ctx.strokeStyle = 'rgba(255,255,255,0.65)'
|
||||||
ctx.lineWidth = 0.8
|
ctx.lineWidth = 0.8
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 外框(全部区域的联合包围盒,逐段加粗)----
|
|
||||||
if (regions.length) {
|
if (regions.length) {
|
||||||
const all = regions.flat()
|
const bb = bboxOf(regions.flat())
|
||||||
const bb = bboxOf(all)
|
|
||||||
const x0 = tx(bb.x0), y0 = ty(bb.y1), x1 = tx(bb.x1), y1 = ty(bb.y0)
|
const x0 = tx(bb.x0), y0 = ty(bb.y1), x1 = tx(bb.x1), y1 = ty(bb.y0)
|
||||||
const ew = shape.fm?.ew ?? [60, 60, 60, 60]
|
const ew = shape.fm?.ew ?? [60, 60, 60, 60]
|
||||||
const avg = ew.reduce((a, b) => a + b, 0) / ew.length
|
const avg = ew.reduce((a, b) => a + b, 0) / ew.length
|
||||||
@@ -101,10 +113,8 @@ function drawFrame(
|
|||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
const inset = Math.max(2.5, avg * 0.018)
|
const inset = Math.max(2.5, avg * 0.018)
|
||||||
ctx.strokeRect(x0 + inset, y0 + inset, (x1 - x0) - inset * 2, (y1 - y0) - inset * 2)
|
ctx.strokeRect(x0 + inset, y0 + inset, (x1 - x0) - inset * 2, (y1 - y0) - inset * 2)
|
||||||
void glass // 保留引用
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 扇(开向标记)----
|
|
||||||
for (const sub of (shape.sm ?? []) as SashShape[]) {
|
for (const sub of (shape.sm ?? []) as SashShape[]) {
|
||||||
const bd = sub.bd
|
const bd = sub.bd
|
||||||
if (!bd || bd.hidden) continue
|
if (!bd || bd.hidden) continue
|
||||||
@@ -113,18 +123,15 @@ function drawFrame(
|
|||||||
const w = bx1 - bx0, h = by1 - by0
|
const w = bx1 - bx0, h = by1 - by0
|
||||||
if (w < 4 || h < 4) continue
|
if (w < 4 || h < 4) continue
|
||||||
ctx.save()
|
ctx.save()
|
||||||
// 扇框(虚线内框)
|
|
||||||
ctx.setLineDash([5, 4])
|
ctx.setLineDash([5, 4])
|
||||||
ctx.strokeStyle = 'rgba(70,76,82,0.9)'
|
ctx.strokeStyle = 'rgba(70,76,82,0.9)'
|
||||||
ctx.lineWidth = 1.3
|
ctx.lineWidth = 1.3
|
||||||
ctx.strokeRect(bx0 + 2, by0 + 2, w - 4, h - 4)
|
ctx.strokeRect(bx0 + 2, by0 + 2, w - 4, h - 4)
|
||||||
ctx.setLineDash([])
|
ctx.setLineDash([])
|
||||||
// 开向三角:od 为角度(度),屏幕系 y 翻转
|
|
||||||
const ang = ((sub.od ?? 0) * Math.PI) / 180
|
const ang = ((sub.od ?? 0) * Math.PI) / 180
|
||||||
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
|
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
|
||||||
const L = Math.min(w, h) * 0.32
|
const L = Math.min(w, h) * 0.32
|
||||||
const ax = cx + Math.cos(ang) * L, ay = cy - Math.sin(ang) * L
|
const ax = cx + Math.cos(ang) * L, ay = cy - Math.sin(ang) * L
|
||||||
// 垂直方向的两翼
|
|
||||||
const wx = Math.cos(ang + Math.PI / 2) * L * 0.38
|
const wx = Math.cos(ang + Math.PI / 2) * L * 0.38
|
||||||
const wy = -Math.sin(ang + Math.PI / 2) * L * 0.38
|
const wy = -Math.sin(ang + Math.PI / 2) * L * 0.38
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
@@ -145,10 +152,8 @@ function drawFrame(
|
|||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 总尺寸标注(宽/高)----
|
|
||||||
if (regions.length) {
|
if (regions.length) {
|
||||||
const all = regions.flat()
|
const bb = bboxOf(regions.flat())
|
||||||
const bb = bboxOf(all)
|
|
||||||
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y1), y1 = ty(bb.y0)
|
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y1), y1 = ty(bb.y0)
|
||||||
const wmm = Math.round(bb.x1 - bb.x0), hmm = Math.round(bb.y1 - bb.y0)
|
const wmm = Math.round(bb.x1 - bb.x0), hmm = Math.round(bb.y1 - bb.y0)
|
||||||
ctx.fillStyle = '#7a8087'
|
ctx.fillStyle = '#7a8087'
|
||||||
@@ -162,7 +167,6 @@ function drawFrame(
|
|||||||
ctx.restore()
|
ctx.restore()
|
||||||
ctx.strokeStyle = 'rgba(122,128,135,0.6)'
|
ctx.strokeStyle = 'rgba(122,128,135,0.6)'
|
||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
// 尺寸线
|
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(x0, y1 + 6); ctx.lineTo(x1, y1 + 6)
|
ctx.moveTo(x0, y1 + 6); ctx.lineTo(x1, y1 + 6)
|
||||||
ctx.moveTo(x0 - 6, y0); ctx.lineTo(x0 - 6, y1)
|
ctx.moveTo(x0 - 6, y0); ctx.lineTo(x0 - 6, y1)
|
||||||
@@ -170,6 +174,121 @@ function drawFrame(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================= 编辑:区域分割 =================
|
||||||
|
|
||||||
|
function pointInPoly(p: Pt, poly: Pt[]): boolean {
|
||||||
|
let inside = false
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||||
|
const [xi, yi] = [poly[i].x, poly[i].y]
|
||||||
|
const [xj, yj] = [poly[j].x, poly[j].y]
|
||||||
|
if ((yi > p.y) !== (yj > p.y) && p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi) inside = !inside
|
||||||
|
}
|
||||||
|
return inside
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按垂直/水平线分割简单多边形(两次交叉点法,适用于矩形/凸区域——样板库区域均为此类) */
|
||||||
|
function splitPoly(region: Segment[], axis: 'v' | 'h', pos: number):
|
||||||
|
{ a: Segment[]; b: Segment[] } | null {
|
||||||
|
const pts = regionPoints(region)
|
||||||
|
const n = pts.length
|
||||||
|
const get = (p: Pt) => (axis === 'v' ? p.x : p.y)
|
||||||
|
const crossings: { i: number; p: Pt }[] = []
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = pts[i], b = pts[(i + 1) % n]
|
||||||
|
const da = get(a) - pos, db = get(b) - pos
|
||||||
|
if ((da < 0 && db > 0) || (da > 0 && db < 0)) {
|
||||||
|
const t = da / (da - db)
|
||||||
|
crossings.push({
|
||||||
|
i,
|
||||||
|
p: axis === 'v'
|
||||||
|
? { x: pos, y: a.y + (b.y - a.y) * t }
|
||||||
|
: { x: a.x + (b.x - a.x) * t, y: pos },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (crossings.length !== 2) return null // 0或多次穿越:不支持(非凸/贴边)
|
||||||
|
const [c1, c2] = crossings
|
||||||
|
if (c1.i === c2.i) return null // 同一条边穿越两次,退化
|
||||||
|
|
||||||
|
// 沿边界收集两侧点列
|
||||||
|
const collect = (from: typeof c1, to: typeof c2): Pt[] => {
|
||||||
|
const ps: Pt[] = [from.p]
|
||||||
|
for (let k = (from.i + 1) % n; ; k = (k + 1) % n) {
|
||||||
|
ps.push(pts[k])
|
||||||
|
if (k === to.i) break
|
||||||
|
}
|
||||||
|
ps.push(to.p)
|
||||||
|
return ps.filter((p, i) => i === 0 || Math.abs(p.x - ps[i - 1].x) > 0.01 || Math.abs(p.y - ps[i - 1].y) > 0.01)
|
||||||
|
}
|
||||||
|
const toSegs = (ps: Pt[]): Segment[] => {
|
||||||
|
const segs: Segment[] = []
|
||||||
|
for (let i = 0; i < ps.length; i++) segs.push(mkSeg(ps[i], ps[(i + 1) % ps.length]))
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
const polyA = collect(c1, c2)
|
||||||
|
const polyB = collect(c2, c1)
|
||||||
|
if (polyA.length < 3 || polyB.length < 3) return null
|
||||||
|
return { a: toSegs(polyA), b: toSegs(polyB) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e: MouseEvent) {
|
||||||
|
if (!editable() || !tool.value || !dd) return
|
||||||
|
const rect = cvRef.value!.getBoundingClientRect()
|
||||||
|
const click = toModel(e.clientX - rect.left, e.clientY - rect.top)
|
||||||
|
undoStack.push(JSON.stringify(dd))
|
||||||
|
const axis = tool.value
|
||||||
|
for (const shape of dd.sm) {
|
||||||
|
const polyWrap = shape.polygon
|
||||||
|
const regions = polyWrap?.polygon ?? []
|
||||||
|
for (let ri = 0; ri < regions.length; ri++) {
|
||||||
|
const pts = regionPoints(regions[ri])
|
||||||
|
if (!pointInPoly(click, pts)) continue
|
||||||
|
const pos = axis === 'v' ? click.x : click.y
|
||||||
|
const split = splitPoly(regions[ri], axis, pos)
|
||||||
|
if (!split) { undoStack.pop(); ElMessage.warning('分割位置太靠近边缘'); return }
|
||||||
|
// 生成两段新区域的闭合段链
|
||||||
|
const chain = (segs: Segment[]): Segment[] => {
|
||||||
|
// 简单闭合:在首尾间补一段
|
||||||
|
const first = segs[0].ps, last = segs[segs.length - 1].pe
|
||||||
|
if (Math.abs(first.x - last.x) > 0.01 || Math.abs(first.y - last.y) > 0.01) segs.push(mkSeg(last, first))
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
const newRegions = [chain(split.a), chain(split.b)]
|
||||||
|
regions.splice(ri, 1, ...newRegions)
|
||||||
|
// 追加分割线记录(与目标站 mm.sp 同构)
|
||||||
|
const mid = axis === 'v' ? { x: pos, y: (pts.reduce((s, p) => s + p.y, 0) / pts.length) } : { x: (pts.reduce((s, p) => s + p.x, 0) / pts.length), y: pos }
|
||||||
|
const norm = axis === 'v' ? { x: 1, y: 0 } : { x: 0, y: -1 }
|
||||||
|
;(shape.mm ??= { sp: [] }).sp.push({
|
||||||
|
line: { pt: mid, norm, name: 'line' },
|
||||||
|
polyId: { idx: ri, pos: -1 },
|
||||||
|
alignType: 0, equalSplit: 0, kw: axis === 'v', ws: false, reinforced: false, db: false,
|
||||||
|
} as any)
|
||||||
|
computeModelBox()
|
||||||
|
render()
|
||||||
|
emit('change')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkSeg(a: Pt, b: Pt): Segment { return { ps: a, pe: b, name: 'segment' } }
|
||||||
|
|
||||||
|
function editable() { return !!props.editable }
|
||||||
|
function arm(t: 'v' | 'h') { tool.value = tool.value === t ? '' : t; info.value = tool.value ? '点击玻璃区域完成分割' : '' }
|
||||||
|
function undo() {
|
||||||
|
if (!undoStack.length || !dd) return
|
||||||
|
const snap = undoStack.pop()!
|
||||||
|
dd = JSON.parse(snap)
|
||||||
|
computeModelBox()
|
||||||
|
render()
|
||||||
|
emit('change')
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportDrawing(): string | null {
|
||||||
|
return dd ? JSON.stringify(dd) : null
|
||||||
|
}
|
||||||
|
defineExpose({ exportDrawing })
|
||||||
|
|
||||||
function computeModelBox() {
|
function computeModelBox() {
|
||||||
if (!dd) return
|
if (!dd) return
|
||||||
const pts: Pt[] = []
|
const pts: Pt[] = []
|
||||||
@@ -177,7 +296,6 @@ function computeModelBox() {
|
|||||||
for (const r of (s.polygon?.polygon ?? [])) {
|
for (const r of (s.polygon?.polygon ?? [])) {
|
||||||
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
|
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
|
||||||
}
|
}
|
||||||
// 扇包围盒也计入
|
|
||||||
for (const sub of (s.sm ?? []) as SashShape[]) {
|
for (const sub of (s.sm ?? []) as SashShape[]) {
|
||||||
if (sub.bd) { pts.push(sub.bd.from, sub.bd.to) }
|
if (sub.bd) { pts.push(sub.bd.from, sub.bd.to) }
|
||||||
}
|
}
|
||||||
@@ -196,7 +314,7 @@ function onWheel(e: WheelEvent) {
|
|||||||
view.scale = Math.min(40, Math.max(0.2, view.scale * factor))
|
view.scale = Math.min(40, Math.max(0.2, view.scale * factor))
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
function onDown(e: MouseEvent) { drag = { x: e.clientX, y: e.clientY } }
|
function onDown(e: MouseEvent) { if (!tool.value) drag = { x: e.clientX, y: e.clientY } }
|
||||||
function onMove(e: MouseEvent) {
|
function onMove(e: MouseEvent) {
|
||||||
if (!drag) return
|
if (!drag) return
|
||||||
view.cx += e.clientX - drag.x
|
view.cx += e.clientX - drag.x
|
||||||
@@ -210,9 +328,10 @@ onMounted(() => {
|
|||||||
watch(() => props.drawingData, () => {
|
watch(() => props.drawingData, () => {
|
||||||
try {
|
try {
|
||||||
dd = props.drawingData ? parseDrawing(props.drawingData) : null
|
dd = props.drawingData ? parseDrawing(props.drawingData) : null
|
||||||
|
undoStack.length = 0
|
||||||
computeModelBox()
|
computeModelBox()
|
||||||
reset()
|
reset()
|
||||||
} catch (e) {
|
} catch {
|
||||||
info.value = '数据解析失败'
|
info.value = '数据解析失败'
|
||||||
dd = null
|
dd = null
|
||||||
render()
|
render()
|
||||||
@@ -228,5 +347,5 @@ onMounted(() => {
|
|||||||
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
|
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
|
||||||
canvas:active { cursor: grabbing; }
|
canvas:active { cursor: grabbing; }
|
||||||
.hud { position: absolute; right: 10px; top: 10px; display: flex; gap: 8px; align-items: center;
|
.hud { position: absolute; right: 10px; top: 10px; display: flex; gap: 8px; align-items: center;
|
||||||
background: rgba(255,255,255,0.85); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; }
|
background: rgba(255,255,255,0.9); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -76,7 +76,18 @@
|
|||||||
<el-button size="small" @click="printOrder">打印订单</el-button>
|
<el-button size="small" @click="printOrder">打印订单</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="canvas-box">
|
<div class="canvas-box">
|
||||||
<WindoorCanvas :drawing-data="detail.drawing_data" />
|
<WindoorCanvas ref="editRef" :drawing-data="detail.drawing_data"
|
||||||
|
:editable="editing" @change="dirty = true" />
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px">
|
||||||
|
<template v-if="!editing">
|
||||||
|
<el-button size="small" @click="editing = true">编辑分格</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="tip">+竖梃/+横梃 → 点击玻璃区域分割</span>
|
||||||
|
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||||
|
<el-button size="small" type="primary" :disabled="!dirty" @click="saveDrawing">保存图纸</el-button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<el-divider>生产进度</el-divider>
|
<el-divider>生产进度</el-divider>
|
||||||
<el-steps :active="processActive" align-center>
|
<el-steps :active="processActive" align-center>
|
||||||
@@ -200,13 +211,34 @@ const detailVisible = ref(false)
|
|||||||
const detail = ref<any>(null)
|
const detail = ref<any>(null)
|
||||||
const processForm = reactive({ name: '下料', status: 1 })
|
const processForm = reactive({ name: '下料', status: 1 })
|
||||||
const processActive = ref(0)
|
const processActive = ref(0)
|
||||||
|
const editing = ref(false)
|
||||||
|
const dirty = ref(false)
|
||||||
|
const editRef = ref()
|
||||||
|
|
||||||
async function openDetail(row: any) {
|
async function openDetail(row: any) {
|
||||||
detail.value = await api<any>('get', `/api/orders/${row.id}`)
|
detail.value = await api<any>('get', `/api/orders/${row.id}`)
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
|
editing.value = false
|
||||||
|
dirty.value = false
|
||||||
processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length
|
processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing.value = false
|
||||||
|
dirty.value = false
|
||||||
|
api<any>('get', `/api/orders/${detail.value.id}`).then(d => (detail.value = d))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDrawing() {
|
||||||
|
const drawing = editRef.value?.exportDrawing()
|
||||||
|
if (!drawing) return
|
||||||
|
await api('put', `/api/orders/${detail.value.id}`, { drawing_data: drawing })
|
||||||
|
ElMessage.success('图纸已保存')
|
||||||
|
editing.value = false
|
||||||
|
dirty.value = false
|
||||||
|
detail.value = await api<any>('get', `/api/orders/${detail.value.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
async function updateProcess() {
|
async function updateProcess() {
|
||||||
await api('post', `/api/orders/${detail.value.id}/process`, processForm)
|
await api('post', `/api/orders/${detail.value.id}/process`, processForm)
|
||||||
ElMessage.success('进度已更新')
|
ElMessage.success('进度已更新')
|
||||||
|
|||||||
@@ -23,13 +23,22 @@
|
|||||||
<el-dialog v-model="previewVisible" :title="current?.name || `样板#${current?.id}`" width="72%" top="4vh">
|
<el-dialog v-model="previewVisible" :title="current?.name || `样板#${current?.id}`" width="72%" top="4vh">
|
||||||
<div v-if="current" class="preview">
|
<div v-if="current" class="preview">
|
||||||
<div class="canvas-box">
|
<div class="canvas-box">
|
||||||
<WindoorCanvas :drawing-data="current.drawing_data" />
|
<WindoorCanvas ref="editRef" :drawing-data="current.drawing_data"
|
||||||
|
:editable="editing" @change="dirty = true" />
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
|
<template v-if="!editing">
|
||||||
<span>尺寸限制: {{ current.low_width }}-{{ current.high_width }} × {{ current.low_height }}-{{ current.high_height }} mm</span>
|
<span>尺寸限制: {{ current.low_width }}-{{ current.high_width }} × {{ current.low_height }}-{{ current.high_height }} mm</span>
|
||||||
<span>引用次数: {{ current.times }}</span>
|
<span>引用次数: {{ current.times }}</span>
|
||||||
<el-button type="success" size="small" @click="quoteRef.open({ template_id: current.id })">算料报价</el-button>
|
<el-button type="success" size="small" @click="quoteRef.open({ template_id: current.id })">算料报价</el-button>
|
||||||
|
<el-button size="small" @click="editing = true">编辑分格</el-button>
|
||||||
<el-button type="primary" size="small" @click="useInOrder">用此样板下单</el-button>
|
<el-button type="primary" size="small" @click="useInOrder">用此样板下单</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="tip">左侧工具:+竖梃/+横梃 → 点击玻璃区域完成分割,支持撤销</span>
|
||||||
|
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||||
|
<el-button type="primary" size="small" :disabled="!dirty" @click="saveAsTemplate">另存为我的样板</el-button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -40,6 +49,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import WindoorCanvas from '../components/WindoorCanvas.vue'
|
import WindoorCanvas from '../components/WindoorCanvas.vue'
|
||||||
import QuoteDialog from '../components/QuoteDialog.vue'
|
import QuoteDialog from '../components/QuoteDialog.vue'
|
||||||
@@ -56,6 +66,9 @@ const catelogs = ref<any[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
const current = ref<any>(null)
|
const current = ref<any>(null)
|
||||||
|
const editing = ref(false)
|
||||||
|
const dirty = ref(false)
|
||||||
|
const editRef = ref()
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -70,9 +83,32 @@ async function load() {
|
|||||||
|
|
||||||
async function preview(t: any) {
|
async function preview(t: any) {
|
||||||
current.value = await api<any>('get', `/api/windoor/templates/${t.id}`)
|
current.value = await api<any>('get', `/api/windoor/templates/${t.id}`)
|
||||||
|
editing.value = false
|
||||||
|
dirty.value = false
|
||||||
previewVisible.value = true
|
previewVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing.value = false
|
||||||
|
dirty.value = false
|
||||||
|
// 重新拉取以还原画布
|
||||||
|
const id = current.value.id
|
||||||
|
api<any>('get', `/api/windoor/templates/${id}`).then(d => (current.value = d))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAsTemplate() {
|
||||||
|
const drawing = editRef.value?.exportDrawing()
|
||||||
|
if (!drawing) return
|
||||||
|
const base = current.value.name || `样板${current.value.id}`
|
||||||
|
const saved = await api<any>('post', '/api/windoor/templates', {
|
||||||
|
name: `${base}-改`, catelog_id: current.value.catelog_id,
|
||||||
|
drawing_data: drawing, note: `基于样板#${current.value.id}修改`,
|
||||||
|
})
|
||||||
|
ElMessage.success(`已另存为我的样板 #${saved.id}`)
|
||||||
|
editing.value = false
|
||||||
|
dirty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function useInOrder() {
|
function useInOrder() {
|
||||||
router.push({ path: '/orders', query: { template: current.value.id } })
|
router.push({ path: '/orders', query: { template: current.value.id } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,25 @@ async def search_templates(
|
|||||||
} for t in items])
|
} for t in items])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates")
|
||||||
|
async def save_template(body: dict, db: DbSession, user: CurrentUser):
|
||||||
|
"""保存自定义样板(编辑器另存)。body: {name, catelog_id, drawing_data, note?}"""
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from huamenchuang.schemas.windoor import TemplateSave
|
||||||
|
try:
|
||||||
|
data = TemplateSave(**body)
|
||||||
|
except (ValidationError, TypeError):
|
||||||
|
return fail("参数错误: 需要 name 与 drawing_data")
|
||||||
|
t = WindoorTemplate(
|
||||||
|
name=data.name, drawing_data=data.drawing_data, pic_url="",
|
||||||
|
catelog_id=data.catelog_id or 0, note=data.note or "",
|
||||||
|
is_default=0, company_id=user.company_id,
|
||||||
|
)
|
||||||
|
db.add(t)
|
||||||
|
await db.flush()
|
||||||
|
return ok(data={"id": t.id}, message="样板已保存")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/templates/{template_id}")
|
@router.get("/templates/{template_id}")
|
||||||
async def template_detail(template_id: int, db: DbSession, user: CurrentUser):
|
async def template_detail(template_id: int, db: DbSession, user: CurrentUser):
|
||||||
"""模板详情:返回 drawing_data(画布可直接 openFile 渲染)。"""
|
"""模板详情:返回 drawing_data(画布可直接 openFile 渲染)。"""
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""样板库 schemas。"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateSave(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
drawing_data: str = Field(min_length=2)
|
||||||
|
catelog_id: int | None = None
|
||||||
|
note: str = ""
|
||||||
Reference in New Issue
Block a user