feat: v0.5 画布尺寸编辑+自动报价+扇样式保真修复
- 参数化尺寸编辑: W/H输入框 + 拖拽外框右缘/下缘/右下角手柄 - 全部几何按比例自动缩放(型材料宽不变, 最小300mm) - 拖拽过程静默更新, 松手提交或回滚撤销点 - 自动报价: 画布变化600ms防抖调用offer-price, 实时显示总价 - 修复样板显示为默认矩形的问题: 扇样式推断引擎三级解析 (有效bd → polyId区域映射双扇对半 → 帧bbox按扇数竖向均分) - 还原真实分格与开向(od角度/inward空心/门标注) - 无图纸订单显示引导提示
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
- [2026-09-20 10:39] FEAT: v0.5 画布尺寸编辑+自动报价+扇样式保真修复——①参数化尺寸编辑: W/H输入框与拖拽外框右缘/下缘/右下角手柄, 全部几何按比例自动缩放(料宽不变,最小300mm), 拖拽静默/松手提交, 撤销栈; ②自动报价: 画布变化600ms防抖自动调offer-price并实时显示总价(样板库/订单详情); ③修复"样板显示为默认样式": 扇样式推断引擎三级解析(有效bd→polyId区域映射双扇对半→帧bbox按扇数竖向均分), 还原真实分格/开向(od角度/inward空心标记/门标注), 无图纸订单显示引导提示 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/views/*, doc/API.md)
|
||||||
- [2026-09-20 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: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)
|
||||||
|
|||||||
+4
-2
@@ -288,8 +288,10 @@
|
|||||||
| `/warehouse` | 仓库库存 | 材料 CRUD + 出入库(流水) + 库存预警高亮 |
|
| `/warehouse` | 仓库库存 | 材料 CRUD + 出入库(流水) + 库存预警高亮 |
|
||||||
| `/optimize` | 优化下料 | 需求/原料编辑 + FFD 计算结果(利用率/切割组合表) |
|
| `/optimize` | 优化下料 | 需求/原料编辑 + FFD 计算结果(利用率/切割组合表) |
|
||||||
|
|
||||||
**画布渲染器**(`components/WindoorCanvas.vue`,自研 Canvas2D):解析 drawing_data → Frame 玻璃区域多边形填充/描边 → 外框加粗 → 扇开向三角标记(od 角度) → 总宽高尺寸标注;支持滚轮缩放、拖拽平移、双击复位。
|
**画布渲染器**(`components/WindoorCanvas.vue`,自研 Canvas2D):解析 drawing_data → Frame 玻璃区域多边形填充/描边 → 外框加粗 → 尺寸标注;支持滚轮缩放、拖拽平移、双击复位。
|
||||||
**画布编辑器**(v0.4,`editable` 模式):+竖梃/+横梃工具 → 点击玻璃区域完成分割(多边形两次交叉点法实时重算区域,mm.sp 同步追加分割线记录),支持撤销;订单详情可"保存图纸"(PUT orders/{id}),样板库可"另存为我的样板"(POST windoor/templates)。
|
**扇样式推断引擎**(v0.5):子形状按 `有效bd → polyId映射区域(双扇对半分) → 帧bbox按扇数竖向均分` 三级解析,还原每套样板的真实分格与开向(od 角度三角标记、inward/outward 空心/实心、门标注)——修复此前"所有样板显示为默认矩形"的问题。
|
||||||
|
**画布编辑器**:+竖梃/+横梃 → 点击玻璃区域完成分割(两次交叉点法实时重算区域,mm.sp 同步追加分割线记录),支持撤销;**尺寸参数化编辑**(v0.5):W/H 输入框或拖拽外框右缘/下缘/右下角手柄 → 全部几何按比例自动缩放(型材料宽不变,最小 300mm),拖拽中静默、松手提交/回滚;订单详情可"保存图纸"(PUT orders/{id}),样板库可"另存为我的样板"(POST windoor/templates)。
|
||||||
|
**自动报价**(v0.5):画布变化(改尺寸/加分格)后 600ms 防抖自动调用 offer-price,页面实时显示当前尺寸总价。
|
||||||
**报价弹窗**(`components/QuoteDialog.vue`):明细表 + 总价,可从样板库/订单详情唤起,支持浏览器打印。
|
**报价弹窗**(`components/QuoteDialog.vue`):明细表 + 总价,可从样板库/订单详情唤起,支持浏览器打印。
|
||||||
|
|
||||||
### 12.11 种子数据
|
### 12.11 种子数据
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
<div class="hud">
|
<div class="hud">
|
||||||
<span v-if="info">{{ info }}</span>
|
<span v-if="info">{{ info }}</span>
|
||||||
<template v-if="editable">
|
<template v-if="editable">
|
||||||
|
<span class="dim-input">W <el-input-number v-model="sizeW" :min="300" :step="10" size="small"
|
||||||
|
controls-position="right" style="width:104px" @change="onSizeInput" /></span>
|
||||||
|
<span class="dim-input">H <el-input-number v-model="sizeH" :min="300" :step="10" size="small"
|
||||||
|
controls-position="right" style="width:104px" @change="onSizeInput" /></span>
|
||||||
<el-button size="small" :type="tool === 'v' ? 'primary' : ''" @click="arm('v')">+竖梃</el-button>
|
<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" :type="tool === 'h' ? 'primary' : ''" @click="arm('h')">+横梃</el-button>
|
||||||
<el-button size="small" :disabled="!undoStack.length" @click="undo">撤销</el-button>
|
<el-button size="small" :disabled="!undoStack.length" @click="undo">撤销</el-button>
|
||||||
@@ -17,9 +21,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 二维窗型画布(自研实现)
|
* 二维窗型画布(自研实现)
|
||||||
* 查看模式:渲染玻璃区域/外框/扇开向/尺寸标注,滚轮缩放、拖拽平移、双击复位
|
* 查看:渲染玻璃区域/外框/扇开向/尺寸标注;滚轮缩放、拖拽平移、双击复位
|
||||||
* 编辑模式(传入 editable):点击区域添加竖梃/横梃(多边形实时分割),支持撤销;
|
* 编辑(editable):
|
||||||
* 通过 expose 的 exportDrawing() 取回修改后的 drawing_data
|
* - 尺寸:W/H 输入框 或 拖拽外框右缘/下缘/右下角手柄 → 全部几何按比例缩放(型材料宽不变)
|
||||||
|
* - 分格:+竖梃/+横梃 → 点击玻璃区域完成分割(两次交叉点法),支持撤销
|
||||||
|
* - exportDrawing() 取回修改后的 drawing_data;change 事件通知父组件
|
||||||
*/
|
*/
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
@@ -40,8 +46,11 @@ 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 undoStack: string[] = []
|
||||||
const tool = ref<'' | 'v' | 'h'>('')
|
const tool = ref<'' | 'v' | 'h'>('')
|
||||||
|
const sizeW = ref(0)
|
||||||
|
const sizeH = ref(0)
|
||||||
|
let resizeDrag: null | { axis: 'w' | 'h' | 'wh'; startW: number; startH: number; startX: number; startY: number; snapshot: string } = null
|
||||||
|
|
||||||
// 几何转换缓存(render 时更新,点击命中用)
|
// 几何转换缓存(render 时更新,命中测试用)
|
||||||
let lastTransform = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
|
let lastTransform = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
|
||||||
|
|
||||||
function toModel(sx: number, sy: number): Pt {
|
function toModel(sx: number, sy: number): Pt {
|
||||||
@@ -49,6 +58,63 @@ function toModel(sx: number, sy: number): Pt {
|
|||||||
return { x: (sx - W / 2 - view.cx) / scale + midX, y: (H / 2 + view.cy - sy) / scale + midY }
|
return { x: (sx - W / 2 - view.cx) / scale + midX, y: (H / 2 + view.cy - sy) / scale + midY }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 参数化尺寸缩放:全部几何按比例缩放,型材料宽(ew)不变 ============
|
||||||
|
|
||||||
|
function scaleAll(sx: number, sy: number) {
|
||||||
|
if (!dd) return
|
||||||
|
const sc = (p: Pt) => { p.x *= sx; p.y *= sy }
|
||||||
|
for (const shape of dd.sm) {
|
||||||
|
for (const r of (shape.polygon?.polygon ?? [])) {
|
||||||
|
for (const seg of r) { sc(seg.ps); sc(seg.pe) }
|
||||||
|
}
|
||||||
|
for (const s of ((shape.mm ??= { sp: [] }).sp ?? [])) sc(s.line.pt)
|
||||||
|
for (const sub of (shape.sm ?? []) as SashShape[]) {
|
||||||
|
if (sub.bd) { sc(sub.bd.from); sc(sub.bd.to) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeTo(newW: number, newH: number, silent = false) {
|
||||||
|
if (!dd) return
|
||||||
|
newW = Math.max(300, Math.round(newW))
|
||||||
|
newH = Math.max(300, Math.round(newH))
|
||||||
|
const w = modelBox.x1 - modelBox.x0, h = modelBox.y1 - modelBox.y0
|
||||||
|
if (w < 10 || h < 10) return
|
||||||
|
if (Math.abs(newW - w) < 0.5 && Math.abs(newH - h) < 0.5) { syncSizeInputs(); return }
|
||||||
|
scaleAll(newW / w, newH / h)
|
||||||
|
computeModelBox()
|
||||||
|
syncSizeInputs()
|
||||||
|
render()
|
||||||
|
if (!silent) emit('change')
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSizeInputs() {
|
||||||
|
sizeW.value = Math.round(modelBox.x1 - modelBox.x0)
|
||||||
|
sizeH.value = Math.round(modelBox.y1 - modelBox.y0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSizeInput() {
|
||||||
|
if (!dd) return
|
||||||
|
undoStack.push(JSON.stringify(dd))
|
||||||
|
resizeTo(sizeW.value, sizeH.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 命中测试:屏幕点是否落在整体外框右缘/下缘/右下角的拖拽缩放带 */
|
||||||
|
function hitResizeHandle(sx: number, sy: number): 'w' | 'h' | 'wh' | null {
|
||||||
|
const { scale, midX, midY, W, H } = lastTransform
|
||||||
|
const x1 = W / 2 + (modelBox.x1 - midX) * scale + view.cx
|
||||||
|
const y1 = H / 2 - (modelBox.y0 - midY) * scale + view.cy
|
||||||
|
const TOL = 8
|
||||||
|
const nearRight = Math.abs(sx - x1) < TOL && sy <= y1 + TOL
|
||||||
|
const nearBottom = Math.abs(sy - y1) < TOL && sx <= x1 + TOL
|
||||||
|
if (nearRight && nearBottom) return 'wh'
|
||||||
|
if (nearRight) return 'w'
|
||||||
|
if (nearBottom) return 'h'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 渲染 ============
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const cv = cvRef.value, wrap = wrapRef.value
|
const cv = cvRef.value, wrap = wrapRef.value
|
||||||
if (!cv || !wrap) return
|
if (!cv || !wrap) return
|
||||||
@@ -70,23 +136,43 @@ function render() {
|
|||||||
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 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, { glassAlpha, profile })
|
||||||
|
|
||||||
|
// 编辑模式:右下角缩放手柄
|
||||||
|
if (props.editable) {
|
||||||
|
const x1 = tx(modelBox.x1), y1 = ty(modelBox.y0)
|
||||||
|
ctx.fillStyle = '#409eff'
|
||||||
|
ctx.fillRect(x1 - 5, y1 - 5, 10, 10)
|
||||||
|
ctx.strokeStyle = '#fff'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.strokeRect(x1 - 5, y1 - 5, 10, 10)
|
||||||
|
ctx.setLineDash([4, 4])
|
||||||
|
ctx.strokeStyle = 'rgba(64,158,255,0.55)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x1 + 6, y1); ctx.lineTo(x1 + 28, y1)
|
||||||
|
ctx.moveTo(x1, y1 + 6); ctx.lineTo(x1, y1 + 28)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
}
|
||||||
|
|
||||||
info.value = dd.sm.length > 1 ? `窗型 · ${dd.sm.length} 个窗体组合` : '窗型'
|
info.value = dd.sm.length > 1 ? `窗型 · ${dd.sm.length} 个窗体组合` : '窗型'
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawFrame(
|
function drawFrame(
|
||||||
ctx: CanvasRenderingContext2D, shape: FrameShape,
|
ctx: CanvasRenderingContext2D, shape: FrameShape,
|
||||||
tx: (x: number) => number, ty: (y: number) => number,
|
tx: (x: number) => number, ty: (y: number) => number,
|
||||||
color: { glass: string; glassAlpha: string; profile: string },
|
color: { glassAlpha: string; profile: string },
|
||||||
) {
|
) {
|
||||||
const regionsRaw = shape.polygon?.polygon ?? []
|
const regionsRaw = shape.polygon?.polygon ?? []
|
||||||
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))))
|
||||||
@@ -101,6 +187,7 @@ function drawFrame(
|
|||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 外框
|
||||||
if (regions.length) {
|
if (regions.length) {
|
||||||
const bb = bboxOf(regions.flat())
|
const bb = bboxOf(regions.flat())
|
||||||
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)
|
||||||
@@ -115,11 +202,73 @@ function drawFrame(
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 扇样式推断(真实分格还原)----
|
||||||
|
// 优先级: 有效bd > polyId映射区域(双扇再对半) > 帧bbox按扇数竖向均分
|
||||||
|
const regionBBs = regions.map(pts => bboxOf(pts))
|
||||||
|
type Glyph = { x0: number; y0: number; x1: number; y1: number; od: number; openToward?: string; isDoor?: boolean }
|
||||||
|
const glyphs: Glyph[] = []
|
||||||
|
const bdRect = (bd: NonNullable<SashShape['bd']>) => {
|
||||||
|
const r = {
|
||||||
|
x0: Math.min(bd.from.x, bd.to.x), y0: Math.min(bd.from.y, bd.to.y),
|
||||||
|
x1: Math.max(bd.from.x, bd.to.x), y1: Math.max(bd.from.y, bd.to.y),
|
||||||
|
}
|
||||||
|
return !bd.hidden && r.x1 - r.x0 > 10 && r.y1 - r.y0 > 10 ? r : null
|
||||||
|
}
|
||||||
|
const pushGlyphs = (r: { x0: number; y0: number; x1: number; y1: number },
|
||||||
|
entries: any[], sub: SashShape) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
glyphs.push({
|
||||||
|
x0: r.x0, y0: r.y0, x1: r.x1, y1: r.y1,
|
||||||
|
od: (e.od ?? sub.od ?? 90) as number,
|
||||||
|
openToward: (e.openToward ?? sub.openToward) as string | undefined,
|
||||||
|
isDoor: Boolean(e.isDoor ?? sub.isDoor),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unmatched: { sub: SashShape; entries: any[] }[] = []
|
||||||
for (const sub of (shape.sm ?? []) as SashShape[]) {
|
for (const sub of (shape.sm ?? []) as SashShape[]) {
|
||||||
const bd = sub.bd
|
const raw = (sub.sashes?.length ? sub.sashes : [sub]) as any[]
|
||||||
if (!bd || bd.hidden) continue
|
const entries = raw.filter(e => e && e.enabled !== false)
|
||||||
const bx0 = tx(Math.min(bd.from.x, bd.to.x)), bx1 = tx(Math.max(bd.from.x, bd.to.x))
|
if (!entries.length) continue
|
||||||
const by0 = ty(Math.max(bd.from.y, bd.to.y)), by1 = ty(Math.min(bd.from.y, bd.to.y))
|
const rect = sub.bd ? bdRect(sub.bd) : null
|
||||||
|
if (rect) { pushGlyphs(rect, entries, sub); continue }
|
||||||
|
const idx = sub.polyId?.idx
|
||||||
|
const rb = idx != null && regionBBs[idx] ? regionBBs[idx] : null
|
||||||
|
if (rb) {
|
||||||
|
if (entries.length === 2) {
|
||||||
|
const w = rb.x1 - rb.x0, h = rb.y1 - rb.y0
|
||||||
|
const halves = w >= h
|
||||||
|
? [{ ...rb, x1: (rb.x0 + rb.x1) / 2 }, { ...rb, x0: (rb.x0 + rb.x1) / 2 }]
|
||||||
|
: [{ ...rb, y1: (rb.y0 + rb.y1) / 2 }, { ...rb, y0: (rb.y0 + rb.y1) / 2 }]
|
||||||
|
entries.forEach((e, k) => pushGlyphs(halves[k], [e], sub))
|
||||||
|
} else {
|
||||||
|
pushGlyphs(rb, entries, sub)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unmatched.push({ sub, entries })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兜底:帧 bbox 按剩余扇数竖向均分
|
||||||
|
if (unmatched.length && regions.length) {
|
||||||
|
const fb = bboxOf(regions.flat())
|
||||||
|
const K = unmatched.reduce((s, u) => s + u.entries.length, 0)
|
||||||
|
const colW = (fb.x1 - fb.x0) / K
|
||||||
|
let i = 0
|
||||||
|
for (const u of unmatched) {
|
||||||
|
for (const e of u.entries) {
|
||||||
|
glyphs.push({
|
||||||
|
x0: fb.x0 + i * colW, x1: fb.x0 + (i + 1) * colW, y0: fb.y0, y1: fb.y1,
|
||||||
|
od: (e.od ?? u.sub.od ?? 90) as number,
|
||||||
|
openToward: (e.openToward ?? u.sub.openToward) as string | undefined,
|
||||||
|
isDoor: Boolean(e.isDoor ?? u.sub.isDoor),
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 绘制扇:虚线内框 + 开向三角(inward 空心 / outward 实心)
|
||||||
|
for (const g of glyphs) {
|
||||||
|
const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y1), by1 = ty(g.y0)
|
||||||
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()
|
||||||
@@ -128,7 +277,7 @@ function drawFrame(
|
|||||||
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([])
|
||||||
const ang = ((sub.od ?? 0) * Math.PI) / 180
|
const ang = ((g.od || 90) * 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
|
||||||
@@ -139,19 +288,22 @@ function drawFrame(
|
|||||||
ctx.lineTo(cx - wx, cy - wy)
|
ctx.lineTo(cx - wx, cy - wy)
|
||||||
ctx.lineTo(cx + wx, cy + wy)
|
ctx.lineTo(cx + wx, cy + wy)
|
||||||
ctx.closePath()
|
ctx.closePath()
|
||||||
ctx.fillStyle = 'rgba(70,76,82,0.25)'
|
ctx.fillStyle = g.openToward === 'inward' ? 'rgba(255,255,255,0.55)' : 'rgba(70,76,82,0.25)'
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
ctx.strokeStyle = 'rgba(70,76,82,0.95)'
|
ctx.strokeStyle = 'rgba(70,76,82,0.95)'
|
||||||
ctx.lineWidth = 1.4
|
ctx.lineWidth = 1.4
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
if (sub.isDoor) {
|
if (g.isDoor) {
|
||||||
ctx.fillStyle = 'rgba(70,76,82,0.85)'
|
ctx.fillStyle = 'rgba(70,76,82,0.85)'
|
||||||
ctx.font = '10px sans-serif'
|
ctx.font = '10px sans-serif'
|
||||||
ctx.fillText('门', cx - 6, by1 - 6)
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText('门', cx, by1 - 6)
|
||||||
|
ctx.textAlign = 'left'
|
||||||
}
|
}
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 尺寸标注(宽/高,随几何自动更新)
|
||||||
if (regions.length) {
|
if (regions.length) {
|
||||||
const bb = bboxOf(regions.flat())
|
const bb = bboxOf(regions.flat())
|
||||||
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)
|
||||||
@@ -174,7 +326,7 @@ function drawFrame(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================= 编辑:区域分割 =================
|
// ============ 编辑:区域分割(两次交叉点法) ============
|
||||||
|
|
||||||
function pointInPoly(p: Pt, poly: Pt[]): boolean {
|
function pointInPoly(p: Pt, poly: Pt[]): boolean {
|
||||||
let inside = false
|
let inside = false
|
||||||
@@ -186,7 +338,6 @@ function pointInPoly(p: Pt, poly: Pt[]): boolean {
|
|||||||
return inside
|
return inside
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按垂直/水平线分割简单多边形(两次交叉点法,适用于矩形/凸区域——样板库区域均为此类) */
|
|
||||||
function splitPoly(region: Segment[], axis: 'v' | 'h', pos: number):
|
function splitPoly(region: Segment[], axis: 'v' | 'h', pos: number):
|
||||||
{ a: Segment[]; b: Segment[] } | null {
|
{ a: Segment[]; b: Segment[] } | null {
|
||||||
const pts = regionPoints(region)
|
const pts = regionPoints(region)
|
||||||
@@ -206,11 +357,10 @@ function splitPoly(region: Segment[], axis: 'v' | 'h', pos: number):
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (crossings.length !== 2) return null // 0或多次穿越:不支持(非凸/贴边)
|
if (crossings.length !== 2) return null
|
||||||
const [c1, c2] = crossings
|
const [c1, c2] = crossings
|
||||||
if (c1.i === c2.i) return null // 同一条边穿越两次,退化
|
if (c1.i === c2.i) return null
|
||||||
|
|
||||||
// 沿边界收集两侧点列
|
|
||||||
const collect = (from: typeof c1, to: typeof c2): Pt[] => {
|
const collect = (from: typeof c1, to: typeof c2): Pt[] => {
|
||||||
const ps: Pt[] = [from.p]
|
const ps: Pt[] = [from.p]
|
||||||
for (let k = (from.i + 1) % n; ; k = (k + 1) % n) {
|
for (let k = (from.i + 1) % n; ; k = (k + 1) % n) {
|
||||||
@@ -231,8 +381,10 @@ function splitPoly(region: Segment[], axis: 'v' | 'h', pos: number):
|
|||||||
return { a: toSegs(polyA), b: toSegs(polyB) }
|
return { a: toSegs(polyA), b: toSegs(polyB) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mkSeg(a: Pt, b: Pt): Segment { return { ps: a, pe: b, name: 'segment' } }
|
||||||
|
|
||||||
function onClick(e: MouseEvent) {
|
function onClick(e: MouseEvent) {
|
||||||
if (!editable() || !tool.value || !dd) return
|
if (!props.editable || !tool.value || !dd) return
|
||||||
const rect = cvRef.value!.getBoundingClientRect()
|
const rect = cvRef.value!.getBoundingClientRect()
|
||||||
const click = toModel(e.clientX - rect.left, e.clientY - rect.top)
|
const click = toModel(e.clientX - rect.left, e.clientY - rect.top)
|
||||||
undoStack.push(JSON.stringify(dd))
|
undoStack.push(JSON.stringify(dd))
|
||||||
@@ -246,24 +398,21 @@ function onClick(e: MouseEvent) {
|
|||||||
const pos = axis === 'v' ? click.x : click.y
|
const pos = axis === 'v' ? click.x : click.y
|
||||||
const split = splitPoly(regions[ri], axis, pos)
|
const split = splitPoly(regions[ri], axis, pos)
|
||||||
if (!split) { undoStack.pop(); ElMessage.warning('分割位置太靠近边缘'); return }
|
if (!split) { undoStack.pop(); ElMessage.warning('分割位置太靠近边缘'); return }
|
||||||
// 生成两段新区域的闭合段链
|
const newRegions = [split.a, split.b]
|
||||||
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)
|
regions.splice(ri, 1, ...newRegions)
|
||||||
// 追加分割线记录(与目标站 mm.sp 同构)
|
// 追加分割线记录(与 shapeManager 的 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 centroid = pts.reduce((s, p) => ({ x: s.x + p.x / pts.length, y: s.y + p.y / pts.length }), { x: 0, y: 0 })
|
||||||
|
const mid: Pt = axis === 'v' ? { x: pos, y: centroid.y } : { x: centroid.x, y: pos }
|
||||||
const norm = axis === 'v' ? { x: 1, y: 0 } : { x: 0, y: -1 }
|
const norm = axis === 'v' ? { x: 1, y: 0 } : { x: 0, y: -1 }
|
||||||
;(shape.mm ??= { sp: [] }).sp.push({
|
const mm = (shape.mm ??= { sp: [] })
|
||||||
|
mm.sp = mm.sp ?? []
|
||||||
|
mm.sp.push({
|
||||||
line: { pt: mid, norm, name: 'line' },
|
line: { pt: mid, norm, name: 'line' },
|
||||||
polyId: { idx: ri, pos: -1 },
|
polyId: { idx: ri, pos: -1 },
|
||||||
alignType: 0, equalSplit: 0, kw: axis === 'v', ws: false, reinforced: false, db: false,
|
alignType: 0, equalSplit: 0, kw: axis === 'v', ws: false, reinforced: false, db: false,
|
||||||
} as any)
|
} as any)
|
||||||
computeModelBox()
|
computeModelBox()
|
||||||
|
syncSizeInputs()
|
||||||
render()
|
render()
|
||||||
emit('change')
|
emit('change')
|
||||||
return
|
return
|
||||||
@@ -271,15 +420,13 @@ function onClick(e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 arm(t: 'v' | 'h') { tool.value = tool.value === t ? '' : t; info.value = tool.value ? '点击玻璃区域完成分割' : '' }
|
||||||
|
|
||||||
function undo() {
|
function undo() {
|
||||||
if (!undoStack.length || !dd) return
|
if (!undoStack.length || !dd) return
|
||||||
const snap = undoStack.pop()!
|
dd = JSON.parse(undoStack.pop()!)
|
||||||
dd = JSON.parse(snap)
|
|
||||||
computeModelBox()
|
computeModelBox()
|
||||||
|
syncSizeInputs()
|
||||||
render()
|
render()
|
||||||
emit('change')
|
emit('change')
|
||||||
}
|
}
|
||||||
@@ -289,6 +436,8 @@ function exportDrawing(): string | null {
|
|||||||
}
|
}
|
||||||
defineExpose({ exportDrawing })
|
defineExpose({ exportDrawing })
|
||||||
|
|
||||||
|
// ============ 视图控制与交互 ============
|
||||||
|
|
||||||
function computeModelBox() {
|
function computeModelBox() {
|
||||||
if (!dd) return
|
if (!dd) return
|
||||||
const pts: Pt[] = []
|
const pts: Pt[] = []
|
||||||
@@ -314,15 +463,57 @@ 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) { if (!tool.value) drag = { x: e.clientX, y: e.clientY } }
|
|
||||||
|
function onDown(e: MouseEvent) {
|
||||||
|
const rect = cvRef.value!.getBoundingClientRect()
|
||||||
|
if (props.editable && !tool.value) {
|
||||||
|
const hit = hitResizeHandle(e.clientX - rect.left, e.clientY - rect.top)
|
||||||
|
if (hit) {
|
||||||
|
undoStack.push(JSON.stringify(dd))
|
||||||
|
resizeDrag = { axis: hit, startW: modelBox.x1 - modelBox.x0, startH: modelBox.y1 - modelBox.y0,
|
||||||
|
startX: e.clientX, startY: e.clientY, snapshot: '' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drag = { x: e.clientX, y: e.clientY }
|
||||||
|
}
|
||||||
|
|
||||||
function onMove(e: MouseEvent) {
|
function onMove(e: MouseEvent) {
|
||||||
|
// 拖拽缩放
|
||||||
|
if (resizeDrag) {
|
||||||
|
const dpx = e.clientX - resizeDrag.startX
|
||||||
|
const dpy = e.clientY - resizeDrag.startY
|
||||||
|
const { scale } = lastTransform
|
||||||
|
const dw = resizeDrag.axis === 'h' ? 0 : dpx / scale
|
||||||
|
const dh = resizeDrag.axis === 'w' ? 0 : -dpy / scale
|
||||||
|
resizeTo(resizeDrag.startW + dw, resizeDrag.startH + dh, true) // 拖拽中静默,松手后统一提交
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 光标提示
|
||||||
|
if (props.editable && !tool.value && cvRef.value) {
|
||||||
|
const rect = cvRef.value.getBoundingClientRect()
|
||||||
|
const hit = hitResizeHandle(e.clientX - rect.left, e.clientY - rect.top)
|
||||||
|
cvRef.value.style.cursor = hit === 'wh' ? 'nwse-resize' : hit === 'w' ? 'ew-resize' : hit === 'h' ? 'ns-resize' : 'grab'
|
||||||
|
}
|
||||||
if (!drag) return
|
if (!drag) return
|
||||||
view.cx += e.clientX - drag.x
|
view.cx += e.clientX - drag.x
|
||||||
view.cy += e.clientY - drag.y
|
view.cy += e.clientY - drag.y
|
||||||
drag = { x: e.clientX, y: e.clientY }
|
drag = { x: e.clientX, y: e.clientY }
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
function onUp() { drag = null }
|
|
||||||
|
function onUp() {
|
||||||
|
if (resizeDrag) {
|
||||||
|
// 拖拽过程中 resizeTo 走 silent;松手时统一判定是否真变了
|
||||||
|
const changed = Math.abs((modelBox.x1 - modelBox.x0) - resizeDrag.startW) > 0.5 ||
|
||||||
|
Math.abs((modelBox.y1 - modelBox.y0) - resizeDrag.startH) > 0.5
|
||||||
|
if (!changed) undoStack.pop() // 尺寸未变,撤销点作废
|
||||||
|
resizeDrag = null
|
||||||
|
if (changed) emit('change')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
drag = null
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
watch(() => props.drawingData, () => {
|
watch(() => props.drawingData, () => {
|
||||||
@@ -330,6 +521,7 @@ onMounted(() => {
|
|||||||
dd = props.drawingData ? parseDrawing(props.drawingData) : null
|
dd = props.drawingData ? parseDrawing(props.drawingData) : null
|
||||||
undoStack.length = 0
|
undoStack.length = 0
|
||||||
computeModelBox()
|
computeModelBox()
|
||||||
|
syncSizeInputs()
|
||||||
reset()
|
reset()
|
||||||
} catch {
|
} catch {
|
||||||
info.value = '数据解析失败'
|
info.value = '数据解析失败'
|
||||||
@@ -347,5 +539,6 @@ 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.9); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; }
|
background: rgba(255,255,255,0.92); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; }
|
||||||
|
.dim-input { display: inline-flex; align-items: center; gap: 4px; color: #409eff; font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -42,16 +42,31 @@ export interface ColorMap {
|
|||||||
ben?: string
|
ben?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SashEntry {
|
||||||
|
od?: number
|
||||||
|
do?: number
|
||||||
|
opened?: boolean
|
||||||
|
isDoor?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
wtf?: boolean
|
||||||
|
polyId?: { idx: number; pos: number }
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export interface SashShape {
|
export interface SashShape {
|
||||||
type: 'Sash' | string
|
type: 'Sash' | 'DoubleSash' | string
|
||||||
bd?: { from: Pt; to: Pt; hidden?: boolean } // 扇所在区域包围盒
|
bd?: { from: Pt; to: Pt; hidden?: boolean; offset?: Vec } // 扇所在区域包围盒(可能退化)
|
||||||
od?: number // 开向角度
|
od?: number // 开向角度
|
||||||
opened?: boolean
|
opened?: boolean
|
||||||
wtf?: boolean // 带转框
|
wtf?: boolean // 带转框
|
||||||
|
withTurningFrame?: boolean
|
||||||
isDoor?: boolean
|
isDoor?: boolean
|
||||||
polyId?: { idx: number; pos: number }
|
polyId?: { idx: number; pos: number }
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
rid?: string
|
rid?: string
|
||||||
|
openToward?: string // inward/outward
|
||||||
|
rt?: number | boolean // 圆弧半径(弧顶扇)
|
||||||
|
sashes?: SashEntry[] // DoubleSash 的子扇
|
||||||
[k: string]: unknown
|
[k: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,15 +76,17 @@
|
|||||||
<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 ref="editRef" :drawing-data="detail.drawing_data"
|
<WindoorCanvas v-if="detail.drawing_data" ref="editRef" :drawing-data="detail.drawing_data"
|
||||||
:editable="editing" @change="dirty = true" />
|
:editable="editing" @change="onCanvasChange" />
|
||||||
|
<div v-else class="empty-tip">此订单未关联图纸——可从「样板库」选样板下单,或在合同中带图纸创建</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:8px">
|
<div style="margin-top:8px; display:flex; gap:12px; align-items:center">
|
||||||
<template v-if="!editing">
|
<template v-if="!editing">
|
||||||
<el-button size="small" @click="editing = true">编辑分格</el-button>
|
<el-button size="small" @click="editing = true">编辑分格/尺寸</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="tip">+竖梃/+横梃 → 点击玻璃区域分割</span>
|
<span class="tip">改 W/H 或拖右下角手柄缩放;+竖梃/+横梃点击分割</span>
|
||||||
|
<span class="live-price" v-if="liveTotal !== null">当前报价 ¥{{ liveTotal }}</span>
|
||||||
<el-button size="small" @click="cancelEdit">取消</el-button>
|
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||||
<el-button size="small" type="primary" :disabled="!dirty" @click="saveDrawing">保存图纸</el-button>
|
<el-button size="small" type="primary" :disabled="!dirty" @click="saveDrawing">保存图纸</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -214,12 +216,29 @@ const processActive = ref(0)
|
|||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
const dirty = ref(false)
|
const dirty = ref(false)
|
||||||
const editRef = ref()
|
const editRef = ref()
|
||||||
|
const liveTotal = ref<number | null>(null)
|
||||||
|
let quoteTimer: number | undefined
|
||||||
|
|
||||||
|
// 画布变化(改尺寸/加分格)→ 防抖自动重算报价
|
||||||
|
function onCanvasChange() {
|
||||||
|
dirty.value = true
|
||||||
|
clearTimeout(quoteTimer)
|
||||||
|
quoteTimer = window.setTimeout(async () => {
|
||||||
|
const drawing = editRef.value?.exportDrawing()
|
||||||
|
if (!drawing) return
|
||||||
|
try {
|
||||||
|
const r = await api<any>('post', '/api/offer-price', { drawing_data: drawing })
|
||||||
|
liveTotal.value = r.total
|
||||||
|
} catch { /* 报价失败不打断编辑 */ }
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
|
||||||
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
|
editing.value = false
|
||||||
dirty.value = false
|
dirty.value = false
|
||||||
|
liveTotal.value = null
|
||||||
processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length
|
processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +270,9 @@ onMounted(load)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
|
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||||
.detail .meta { display: flex; gap: 16px; color: #606266; font-size: 13px; margin-bottom: 10px; align-items: center; }
|
.detail .meta { display: flex; gap: 16px; color: #606266; font-size: 13px; margin-bottom: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
.canvas-box { height: 440px; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
|
.canvas-box { height: 440px; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
|
||||||
|
.empty-tip { height: 100%; display: flex; align-items: center; justify-content: center; color: #909399; font-size: 14px; }
|
||||||
|
.tip { color: #909399; font-size: 12px; }
|
||||||
|
.live-price { color: #e6483d; font-size: 15px; font-weight: 700; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,18 +24,19 @@
|
|||||||
<div v-if="current" class="preview">
|
<div v-if="current" class="preview">
|
||||||
<div class="canvas-box">
|
<div class="canvas-box">
|
||||||
<WindoorCanvas ref="editRef" :drawing-data="current.drawing_data"
|
<WindoorCanvas ref="editRef" :drawing-data="current.drawing_data"
|
||||||
:editable="editing" @change="dirty = true" />
|
:editable="editing" @change="onCanvasChange" />
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<template v-if="!editing">
|
<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 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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="tip">左侧工具:+竖梃/+横梃 → 点击玻璃区域完成分割,支持撤销</span>
|
<span class="tip">改 W/H 或拖右下角手柄缩放;+竖梃/+横梃点击分割</span>
|
||||||
|
<span class="live-price" v-if="liveTotal !== null">当前报价 ¥{{ liveTotal }}</span>
|
||||||
<el-button size="small" @click="cancelEdit">取消</el-button>
|
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||||
<el-button type="primary" size="small" :disabled="!dirty" @click="saveAsTemplate">另存为我的样板</el-button>
|
<el-button type="primary" size="small" :disabled="!dirty" @click="saveAsTemplate">另存为我的样板</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -69,6 +70,22 @@ const current = ref<any>(null)
|
|||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
const dirty = ref(false)
|
const dirty = ref(false)
|
||||||
const editRef = ref()
|
const editRef = ref()
|
||||||
|
const liveTotal = ref<number | null>(null)
|
||||||
|
let quoteTimer: number | undefined
|
||||||
|
|
||||||
|
// 画布变化(改尺寸/加分格)→ 防抖自动重算报价
|
||||||
|
function onCanvasChange() {
|
||||||
|
dirty.value = true
|
||||||
|
clearTimeout(quoteTimer)
|
||||||
|
quoteTimer = window.setTimeout(async () => {
|
||||||
|
const drawing = editRef.value?.exportDrawing()
|
||||||
|
if (!drawing) return
|
||||||
|
try {
|
||||||
|
const r = await api<any>('post', '/api/offer-price', { drawing_data: drawing })
|
||||||
|
liveTotal.value = r.total
|
||||||
|
} catch { /* 报价失败不打断编辑 */ }
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -85,6 +102,7 @@ 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
|
editing.value = false
|
||||||
dirty.value = false
|
dirty.value = false
|
||||||
|
liveTotal.value = null
|
||||||
previewVisible.value = true
|
previewVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,5 +148,7 @@ onMounted(async () => {
|
|||||||
.pic { width: 100%; height: 110px; }
|
.pic { width: 100%; height: 110px; }
|
||||||
.cap { font-size: 12px; color: #606266; margin-top: 6px; text-align: center; }
|
.cap { font-size: 12px; color: #606266; margin-top: 6px; text-align: center; }
|
||||||
.preview .canvas-box { height: 60vh; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
|
.preview .canvas-box { height: 60vh; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
|
||||||
.preview .meta { display: flex; gap: 20px; margin-top: 10px; color: #606266; font-size: 13px; align-items: center; }
|
.preview .meta { display: flex; gap: 20px; margin-top: 10px; color: #606266; font-size: 13px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.tip { color: #909399; }
|
||||||
|
.live-price { color: #e6483d; font-size: 16px; font-weight: 700; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user