feat: P1批次1 设计域(AI推荐/分享体系/阳光房绘图/量尺标注/逐格指定编辑)

This commit is contained in:
HHY
2026-09-25 06:17:29 -07:00
parent 6726a9fa3a
commit 76fd0ced14
21 changed files with 1154 additions and 20 deletions
+142
View File
@@ -0,0 +1,142 @@
<template>
<canvas ref="cvRef" class="sunroom-cv" />
</template>
<script setup lang="ts">
/**
* 阳光房 2D 参数化绘图(自研):
* 上=正视图(flat 平顶横梁 / gable 人字顶山墙),下=俯视图(周长立柱 + 顶面玻璃分格)
*/
import { onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
roof?: 'flat' | 'gable'
widthMm?: number
depthMm?: number
heightMm?: number
bayMm?: number
}>(), { roof: 'flat', widthMm: 4000, depthMm: 3000, heightMm: 2800, bayMm: 1000 })
const cvRef = ref<HTMLCanvasElement>()
function render() {
const cv = cvRef.value
if (!cv) return
const wrap = cv.parentElement
const W = Math.max(320, wrap?.clientWidth ?? 600)
const H = Math.max(300, wrap?.clientHeight ?? 420)
const dpr = window.devicePixelRatio || 1
cv.width = W * dpr
cv.height = H * dpr
cv.style.height = `${H}px`
const ctx = cv.getContext('2d')!
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
ctx.lineJoin = 'round'
const w = props.widthMm, d = props.depthMm, h = props.heightMm, bay = props.bayMm
const posts = Math.max(2, Math.ceil(w / bay) + 1)
const bays = posts - 1
// ---- 正视图(上 55%)----
const fx0 = W * 0.12, fx1 = W * 0.88
const scale = (fx1 - fx0) / w
const fy0 = H * 0.34
const wallH = h * scale
ctx.strokeStyle = '#4d5257'
ctx.lineWidth = 4
if (props.roof === 'gable') {
// 人字顶: 两侧斜梁 + 屋脊
const ridgeY = fy0 - Math.min(H * 0.16, (d / 2) * scale)
ctx.beginPath()
ctx.moveTo(fx0 - 8, fy0)
ctx.lineTo((fx0 + fx1) / 2, ridgeY)
ctx.lineTo(fx1 + 8, fy0)
ctx.stroke()
// 山墙玻璃分格(沿斜梁)
ctx.lineWidth = 1.4
ctx.strokeStyle = '#8a9098'
for (let i = 1; i < bays; i++) {
const x = fx0 + (fx1 - fx0) * i / bays
ctx.beginPath()
ctx.moveTo(x, fy0)
ctx.lineTo((fx0 + fx1) / 2, ridgeY)
ctx.stroke()
}
} else {
// 平顶: 前檐横梁 + 悬挑
ctx.beginPath()
ctx.moveTo(fx0 - 12, fy0 - 6)
ctx.lineTo(fx1 + 12, fy0 - 6)
ctx.stroke()
}
// 立柱 + 竖玻璃分格
ctx.lineWidth = 6
ctx.strokeStyle = '#565b62'
for (let i = 0; i <= bays; i++) {
const x = fx0 + (fx1 - fx0) * i / bays
ctx.beginPath()
ctx.moveTo(x, fy0)
ctx.lineTo(x, fy0 + wallH)
ctx.stroke()
}
// 玻璃
ctx.fillStyle = 'rgba(158,201,216,0.35)'
ctx.fillRect(fx0 + 3, fy0 + 3, fx1 - fx0 - 6, wallH - 6)
// 地面
ctx.strokeStyle = '#c0b9a8'
ctx.lineWidth = 5
ctx.beginPath()
ctx.moveTo(fx0 - 26, fy0 + wallH + 4)
ctx.lineTo(fx1 + 26, fy0 + wallH + 4)
ctx.stroke()
// ---- 俯视图(下 40%)----
const px0 = W * 0.12, px1 = W * 0.88
const pscale = (px1 - px0) / w
const py0 = H * 0.62
const depPx = d * pscale
ctx.strokeStyle = '#4d5257'
ctx.lineWidth = 4
ctx.strokeRect(px0, py0, px1 - px0, depPx)
// 顶面玻璃分格(横向 bays × 纵向按进深 1m 分)
const rowsD = Math.max(1, Math.ceil(d / bay))
ctx.lineWidth = 1.4
ctx.strokeStyle = '#8a9098'
for (let i = 1; i < bays; i++) {
const x = px0 + (px1 - px0) * i / bays
ctx.beginPath(); ctx.moveTo(x, py0); ctx.lineTo(x, py0 + depPx); ctx.stroke()
}
for (let j = 1; j < rowsD; j++) {
const y = py0 + depPx * j / rowsD
ctx.beginPath(); ctx.moveTo(px0, y); ctx.lineTo(px1, y); ctx.stroke()
}
ctx.fillStyle = 'rgba(158,201,216,0.22)'
ctx.fillRect(px0 + 2, py0 + 2, px1 - px0 - 4, depPx - 4)
if (props.roof === 'gable') {
// 屋脊线(俯视)
ctx.strokeStyle = '#4d5257'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(px0, py0 + depPx / 2)
ctx.lineTo(px1, py0 + depPx / 2)
ctx.stroke()
}
// 尺寸标注
ctx.fillStyle = '#2b6cb0'
ctx.font = '11px sans-serif'
ctx.fillText(`${w} mm`, (fx0 + fx1) / 2 - 24, fy0 + wallH + 22)
ctx.fillText(`${d} mm`, px1 + 8, py0 + depPx / 2)
ctx.fillText(`${h} mm`, fx0 - 44, fy0 + wallH / 2)
}
onMounted(() => {
watch(() => [props.roof, props.widthMm, props.depthMm, props.heightMm, props.bayMm], render, { immediate: true })
window.addEventListener('resize', render)
})
</script>
<style scoped>
.sunroom-cv { width: 100%; display: block; }
</style>
+139 -1
View File
@@ -15,6 +15,7 @@
<span class="dim-input">格高 <el-input-number v-model="regH" :min="100" :step="10" size="small"
controls-position="right" style="width:92px" @change="onRegionSize" /></span>
<el-button size="small" type="warning" plain @click="openDec">格条</el-button>
<el-button size="small" type="success" plain @click="openAssign">扇/玻璃</el-button>
</template>
<template v-else-if="selected">
<span class="dim-input">梃位 <el-input-number v-model="mulPos" :min="0" :step="10" size="small"
@@ -55,6 +56,51 @@
<el-button type="primary" @click="applyDec">应用</el-button>
</template>
</el-dialog>
<!-- 扇/玻璃逐格指定对话框 -->
<el-dialog v-model="assignVisible" title="扇 / 玻璃逐格指定" width="460px" append-to-body>
<el-form label-width="90px" size="small">
<el-form-item label="开启类型">
<el-radio-group v-model="assignForm.sash.kind">
<el-radio-button value="fixed">固定玻璃</el-radio-button>
<el-radio-button value="casement">平开扇</el-radio-button>
<el-radio-button value="awning">上悬</el-radio-button>
<el-radio-button value="sliding">推拉</el-radio-button>
<el-radio-button value="kfc">内开内倒</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="assignForm.sash.kind !== 'fixed'">
<el-form-item label="开启方向">
<el-radio-group v-model="assignForm.sash.openToward">
<el-radio-button value="inward">内开</el-radio-button>
<el-radio-button value="outward">外开</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="铰链侧">
<el-radio-group v-model="assignForm.sash.openDirection" :disabled="assignForm.sash.kind === 'awning'">
<el-radio-button value="left">左侧</el-radio-button>
<el-radio-button value="right">右侧</el-radio-button>
</el-radio-group>
</el-form-item>
</template>
<el-form-item label="副梃">
<el-switch v-model="assignForm.subBar" />
<span class="assign-tip">扇中梃(按长度计价)</span>
</el-form-item>
<el-form-item label="玻璃配置">
<el-select v-model="assignForm.glass" style="width:200px" clearable>
<el-option value="单玻 5mm" /><el-option value="中空 5+12A+5" />
<el-option value="中空 5+18A+5" /><el-option value="三玻两腔 5+9A+5+9A+5" />
<el-option value="夹胶 5+0.76PVB+5" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button v-if="hasAssign" type="warning" plain @click="removeAssign">清除该格指定</el-button>
<el-button @click="assignVisible = false">取消</el-button>
<el-button type="primary" @click="applyAssign">应用</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -77,7 +123,7 @@ import { onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
bboxOf, DEC_PRESETS, inferSashGlyphs, parseDrawing, regionPoints, replayShape, spAxis, spPos,
type DecBar, type DrawingData, type FrameShape, type Pt, type Segment, type SashShape, type SplitLine,
type DecBar, type DrawingData, type FrameShape, type Pt, type SashAssign, type Segment, type SashShape, type SplitLine,
} from '../types/drawing'
const props = defineProps<{ drawingData: string | object; editable?: boolean }>()
@@ -436,6 +482,49 @@ function drawFrame(
ctx.restore()
}
// 1.6) 逐格指定(自研 assigns): 副梃竖线 + 开扇符号 + 玻璃配置角标
for (const a of (shape.assigns ?? []) as SashAssign[]) {
const segs = rp.regions[a.ri]
if (!segs) continue
const apts = regionPoints(segs)
if (apts.length < 3) continue
const abb = bboxOf(apts)
const ax0 = tx(abb.x0), ay0 = ty(abb.y0), ax1 = tx(abb.x1), ay1 = ty(abb.y1)
const aw = ax1 - ax0, ah = ay1 - ay0
if (aw < 14 || ah < 14) continue
const acx = (ax0 + ax1) / 2, acy = (ay0 + ay1) / 2
if (a.subBar) {
ctx.strokeStyle = STYLE.profileDark
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(acx, ay0 + 2); ctx.lineTo(acx, ay1 - 2); ctx.stroke()
}
if (a.sash) {
ctx.save()
ctx.setLineDash([7, 5])
ctx.strokeStyle = 'rgba(40,60,90,0.75)'
ctx.lineWidth = 1.6
ctx.beginPath()
if (a.sash.kind === 'sliding') {
ctx.moveTo(ax0 + 4, acy); ctx.lineTo(ax1 - 4, acy)
ctx.moveTo(ax1 - 10, acy - 4); ctx.lineTo(ax1 - 4, acy); ctx.lineTo(ax1 - 10, acy + 4)
} else if (a.sash.kind === 'awning') {
ctx.moveTo(ax0 + 4, ay1 - 4); ctx.lineTo(ax1 - 4, ay1 - 4); ctx.lineTo(acx, ay0 + 6)
} else {
const hingeLeft = a.sash.openDirection !== 'right'
if (hingeLeft) { ctx.moveTo(ax0 + 5, ay0 + 5); ctx.lineTo(ax0 + 5, ay1 - 5); ctx.lineTo(ax1 - 6, acy) }
else { ctx.moveTo(ax1 - 5, ay0 + 5); ctx.lineTo(ax1 - 5, ay1 - 5); ctx.lineTo(ax0 + 6, acy) }
}
ctx.closePath()
ctx.stroke()
ctx.restore()
}
if (a.glass) {
ctx.fillStyle = 'rgba(43,108,176,0.85)'
ctx.font = `${Math.max(9, Math.min(12, aw / 8))}px sans-serif`
ctx.fillText(a.glass, ax0 + 4, ay1 - 5)
}
}
// 2) 中梃(按目标区域裁剪, 型材三段线; 选中高亮) + 与外框交点白块(官方连接件符号)
for (let spIdx = 0; spIdx < rp.clips.length; spIdx++) {
const { sp, bb } = rp.clips[spIdx]
@@ -919,6 +1008,54 @@ function removeDec() {
emit('change')
ElMessage.success('格条已清除')
}
// ---- 扇/玻璃逐格指定(P1-逐格编辑) ----
const assignVisible = ref(false)
const hasAssign = ref(false)
const assignForm = reactive<SashAssign>({
ri: 0, sash: { kind: 'fixed', openToward: 'inward', openDirection: 'left' }, subBar: false, glass: '',
})
function openAssign() {
if (!selRegion.value || !dd) return
const { shapeIdx, ri } = selRegion.value
const existing = (dd.sm[shapeIdx]?.assigns ?? []).find(a => a.ri === ri)
hasAssign.value = !!existing
const blank: SashAssign = { ri, sash: { kind: 'fixed', openToward: 'inward', openDirection: 'left' }, subBar: false, glass: '' }
Object.assign(assignForm, JSON.parse(JSON.stringify(existing ?? blank)), { ri })
assignVisible.value = true
}
function applyAssign() {
if (!selRegion.value || !dd) return
const { shapeIdx } = selRegion.value
const shape = dd.sm[shapeIdx]
if (!shape) return
undoStack.push(JSON.stringify(dd))
shape.assigns = shape.assigns ?? []
const found = shape.assigns.find(a => a.ri === assignForm.ri)
const copy = JSON.parse(JSON.stringify(assignForm)) as SashAssign
if (found) Object.assign(found, copy)
else shape.assigns.push(copy)
assignVisible.value = false
render()
emit('change')
ElMessage.success('已指定该格')
}
function removeAssign() {
if (!selRegion.value || !dd) return
const { shapeIdx, ri } = selRegion.value
const shape = dd.sm[shapeIdx]
if (!shape?.assigns) return
undoStack.push(JSON.stringify(dd))
shape.assigns = shape.assigns.filter(a => a.ri !== ri)
hasAssign.value = false
assignVisible.value = false
render()
emit('change')
ElMessage.success('已清除该格指定')
}
defineExpose({ exportDrawing })
// ============ 视图控制 ============
@@ -1088,4 +1225,5 @@ canvas:active { cursor: grabbing; }
.dec-preset.active { border-color: #409eff; background: #ecf5ff; }
.dec-thumb { width: 100%; height: 44px; border: 1px solid #dcdfe6; border-radius: 4px; overflow: hidden; }
.dec-thumb-bar { width: 100%; height: 100%; }
.assign-tip { color: #909399; font-size: 12px; margin-left: 8px; }
</style>
+1
View File
@@ -38,6 +38,7 @@ const router = createRouter({
],
},
{ path: '/share/order/:token', component: () => import('../views/ShareOrderView.vue') },
{ path: '/share/s/:token', component: () => import('../views/ShareView.vue') },
],
})
+9
View File
@@ -92,6 +92,14 @@ export const DEC_PRESETS: { kind: string; label: string; rows: number; cols: num
{ kind: 'pastoral', label: '田园密格', rows: 4, cols: 4, bw: 14, color: '#ffffff' },
]
/** 逐格指定(自研扩展:把某玻璃格指定为开扇/玻璃配置/副梃;ri = 重放区域索引) */
export interface SashAssign {
ri: number
sash: { kind: 'fixed' | 'casement' | 'awning' | 'sliding' | 'kfc'; openToward?: 'inward' | 'outward'; openDirection?: 'left' | 'right' }
subBar?: boolean // 副梃(扇中梃)
glass?: string // 玻璃配置描述(如 中空 5+12A+5)
}
export interface FrameShape {
type: 'Frame' | string
polygon?: {
@@ -110,6 +118,7 @@ export interface FrameShape {
uid?: string
titl?: string
decs?: DecBar[] // 装饰格条(自研扩展)
assigns?: SashAssign[] // 逐格扇/玻璃指定(自研扩展)
[k: string]: unknown
}
+12
View File
@@ -92,6 +92,7 @@
<img :src="aiImage" class="ai-img" />
<div class="ai-ops">
<el-button type="primary" size="small" plain @click="downloadAi">下载 PNG</el-button>
<el-button size="small" type="success" plain @click="shareAi">分享此图</el-button>
</div>
<el-collapse class="prompt-box">
<el-collapse-item title="查看优化后的提示词(由窗型数据结构化生成)">
@@ -281,6 +282,17 @@ function downloadAi() {
a.click()
}
async function shareAi() {
if (!aiImage.value) return
const d = await api<any>('post', '/api/shares', {
type: 'render', title: 'AI 炫图',
payload: { image: aiImage.value, desc: `${templateId.value ? `样板#${templateId.value}` : ''} AI 效果图` },
})
const url = `${location.origin}${d.url}`
await navigator.clipboard?.writeText(url).catch(() => undefined)
ElMessage.success(`分享链接已复制: ${url}`)
}
// ---- 风格速览(本地程序化 4 方案, 未配置云端 Key 时的降级模式) ----
async function generateQuick() {
generating.value = true
+20
View File
@@ -61,6 +61,7 @@
<el-input v-model="saveName" placeholder="组合样板名称(如:三扇组合窗)" style="flex:1" />
<el-button type="primary" :disabled="!shapes.length || !saveName.trim()" :loading="saving"
@click="saveAsTemplate">另存为我的样板</el-button>
<el-button type="success" plain :disabled="!shapes.length" @click="shareCombine">分享</el-button>
</div>
</el-card>
</el-col>
@@ -384,6 +385,25 @@ async function saveAsTemplate() {
} finally { saving.value = false }
}
// ---------- 分享组合(group-detail-share 语义:公开只读页) ----------
async function shareCombine() {
const json = canvasRef.value?.exportDrawing()
if (!json) return
const dd = JSON.parse(json)
dd.sm = dd.sm.map((s: any) => {
const { __uid, __name, __templateId, ...rest } = s
void __uid; void __name; void __templateId
return rest
})
const d = await api<any>('post', '/api/shares', {
type: 'combine', title: saveName.value.trim() || '样板组合',
payload: { shapes: [dd] },
})
const url = `${location.origin}${d.url}`
await navigator.clipboard?.writeText(url).catch(() => undefined)
ElMessage.success(`分享链接已复制: ${url}`)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Delete' && selectedUid.value !== null) { deleteSelected(); return }
// 方向键微调选中单元(1mm,Shift=10mm)
+156 -2
View File
@@ -24,9 +24,15 @@
<el-table-column label="洞口数据" width="90">
<template #default="{ row }">{{ (row.data || []).length }} 项</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<el-table-column label="标注" width="80">
<template #default="{ row }">
<el-tag v-if="(row.data || []).some((d: any) => d.photo)" size="small" type="success" effect="plain">已标注</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="warning" plain @click="annotateRow(row)">标注</el-button>
</template>
</el-table-column>
</el-table>
@@ -53,6 +59,8 @@
<el-input-number v-model="d.width" :min="0" placeholder="宽" controls-position="right" style="width:110px" />
<el-input-number v-model="d.height" :min="0" placeholder="高" controls-position="right" style="width:110px" />
<el-input-number v-model="d.qty" :min="1" placeholder="数量" controls-position="right" style="width:90px" />
<el-tag v-if="d.photo" size="small" type="success" effect="plain">已标注</el-tag>
<el-button size="small" text type="warning" @click="openAnno(Number(i))">标注</el-button>
<el-button text type="danger" @click="form.data.splice(i, 1)">删</el-button>
</div>
<el-button text type="primary" @click="form.data.push({ name: '', width: 0, height: 0, qty: 1 })">+ 添加洞口</el-button>
@@ -70,11 +78,32 @@
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
<!-- 拍照标注(对齐源站 measure_annotation:照片上画尺寸线) -->
<el-dialog v-model="annoVisible" :title="`拍照标注 — ${form.data[annoIdx]?.name || '洞口'}`" width="760px" top="4vh">
<div class="anno-bar">
<el-upload :show-file-list="false" accept="image/*" :http-request="onAnnoUpload">
<el-button size="small" type="primary" plain>{{ annoPhoto ? '更换照片' : '上传/拍照' }}</el-button>
</el-upload>
<el-input v-model="annoLabel" size="small" placeholder="标注文字(画线时使用)" style="width:180px" />
<el-color-picker v-model="annoColor" size="small" />
<el-button size="small" @click="undoAnno" :disabled="!annoLines.length">撤销一线</el-button>
<el-button size="small" type="danger" plain @click="annoLines = []">清空</el-button>
<span class="anno-tip">在照片上拖拽画尺寸线,文字取自左侧输入框</span>
</div>
<div class="anno-stage">
<canvas ref="annoCv" @mousedown="annoDown" @mousemove="annoMove" @mouseup="annoUp" @mouseleave="annoUp" />
</div>
<template #footer>
<el-button @click="annoVisible = false">取消</el-button>
<el-button type="primary" @click="saveAnno">完成标注</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
@@ -126,9 +155,134 @@ async function save() {
}
onMounted(load)
// ---- 拍照标注(照片 + 拖拽画线, 存洞口行 photo 字段) ----
const annoVisible = ref(false)
const annoIdx = ref(0)
const annoCv = ref<HTMLCanvasElement>()
const annoLabel = ref('')
const annoColor = ref('#e6483d')
const annoPhoto = ref<HTMLImageElement | null>(null)
let annoLines: { x1: number; y1: number; x2: number; y2: number; label: string; color: string }[] = []
let annoDrag: { x1: number; y1: number; x2: number; y2: number } | null = null
function annotateRow(row: any) {
openEdit(row)
nextTick(() => openAnno(0))
}
function openAnno(i: number) {
if (!form.data?.length) return ElMessage.warning('请先添加洞口')
annoIdx.value = i
const existed = form.data[i]?.photo
annoPhoto.value = null
annoLines = []
if (existed) {
// 载入已有标注照片
const img = new Image()
img.onload = () => { annoPhoto.value = img; annoLines = existed_lines(img, existed) ; drawAnno() }
img.src = existed
}
annoVisible.value = true
nextTick(drawAnno)
}
function existed_lines(_img: HTMLImageElement, _data: string) {
return [] // 已标注图作为底图整体载入, 不再解析历史线段
}
async function onAnnoUpload(opt: any) {
const url = URL.createObjectURL(opt.file as File)
const img = new Image()
img.onload = () => { annoPhoto.value = img; annoLines = []; drawAnno(); URL.revokeObjectURL(url) }
img.src = url
}
function annoCtx() { return (annoCv.value as HTMLCanvasElement)?.getContext('2d') }
function drawAnno() {
const cv = annoCv.value as HTMLCanvasElement | undefined
if (!cv) return
const maxW = Math.min(700, cv.parentElement?.clientWidth ?? 700)
if (annoPhoto.value) {
const scale = Math.min(1, maxW / annoPhoto.value.naturalWidth)
cv.width = Math.round(annoPhoto.value.naturalWidth * scale)
cv.height = Math.round(annoPhoto.value.naturalHeight * scale)
} else {
cv.width = maxW; cv.height = 380
}
const ctx = cv.getContext('2d')!
ctx.clearRect(0, 0, cv.width, cv.height)
if (annoPhoto.value) ctx.drawImage(annoPhoto.value, 0, 0, cv.width, cv.height)
else {
ctx.fillStyle = '#f0f2f5'; ctx.fillRect(0, 0, cv.width, cv.height)
ctx.fillStyle = '#909399'; ctx.font = '14px sans-serif'; ctx.textAlign = 'center'
ctx.fillText('上传量尺照片后拖拽画标注线', cv.width / 2, cv.height / 2)
ctx.textAlign = 'left'
}
const all = [...annoLines, ...(annoDrag ? [{ ...annoDrag, label: '', color: annoColor.value }] : [])]
for (const l of all) {
ctx.strokeStyle = l.color
ctx.fillStyle = l.color
ctx.lineWidth = 2.5
ctx.beginPath(); ctx.moveTo(l.x1, l.y1); ctx.lineTo(l.x2, l.y2); ctx.stroke()
// 端点箭头
for (const [ex, ey] of [[l.x1, l.y1], [l.x2, l.y2]] as [number, number][]) {
ctx.beginPath(); ctx.arc(ex, ey, 3.5, 0, Math.PI * 2); ctx.fill()
}
if (l.label) {
ctx.font = 'bold 13px sans-serif'
const mx = (l.x1 + l.x2) / 2, my = (l.y1 + l.y2) / 2 - 6
const tw = ctx.measureText(l.label).width
ctx.fillStyle = 'rgba(255,255,255,0.85)'
ctx.fillRect(mx - tw / 2 - 3, my - 13, tw + 6, 17)
ctx.fillStyle = l.color
ctx.fillText(l.label, mx - tw / 2, my)
}
}
}
function annoPos(e: MouseEvent) {
const cv = annoCv.value!
const b = cv.getBoundingClientRect()
return { x: (e.clientX - b.left) * (cv.width / b.width), y: (e.clientY - b.top) * (cv.height / b.height) }
}
function annoDown(e: MouseEvent) {
const p = annoPos(e)
annoDrag = { x1: p.x, y1: p.y, x2: p.x, y2: p.y }
}
function annoMove(e: MouseEvent) {
if (!annoDrag) return
const p = annoPos(e)
annoDrag.x2 = p.x; annoDrag.y2 = p.y
drawAnno()
}
function annoUp() {
if (!annoDrag) return
const { x1, y1, x2, y2 } = annoDrag
annoDrag = null
if (Math.hypot(x2 - x1, y2 - y1) > 12) {
annoLines.push({ x1, y1, x2, y2, label: annoLabel.value, color: annoColor.value })
}
drawAnno()
}
function undoAnno() { annoLines.pop(); drawAnno() }
function saveAnno() {
const cv = annoCv.value as HTMLCanvasElement | undefined
if (!cv || !annoPhoto.value) return ElMessage.warning('请先上传照片')
if (form.data[annoIdx.value]) form.data[annoIdx.value].photo = cv.toDataURL('image/jpeg', 0.85)
annoVisible.value = false
ElMessage.success('标注完成, 记得保存量尺单')
}
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; }
.anno-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
.anno-tip { color: #909399; font-size: 12px; }
.anno-stage { display: flex; justify-content: center; }
.anno-stage canvas { max-width: 100%; border: 1px solid #e4e7ed; border-radius: 6px; cursor: crosshair; background: #fff; }
</style>
+20 -1
View File
@@ -21,10 +21,11 @@
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="170" fixed="right">
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="detail(row)">详情</el-button>
<el-button size="small" type="success" plain @click="toOrder(row)">转订单</el-button>
<el-button size="small" type="warning" plain @click="shareQuote(row)">分享</el-button>
<el-button size="small" type="danger" plain @click="remove(row)">删</el-button>
</template>
</el-table-column>
@@ -174,6 +175,24 @@ async function detail(row: any) {
detailVisible.value = true
}
/** 报价分享链接(quote_link 语义:公开只读报价页,客户免登录查看) */
async function shareQuote(row: any) {
const q = await api<any>('get', `/api/quotations/${row.id}`)
const items = (q.items || []).map((i: any) => ({
note: i.name || i.note || '-', length: i.length ? `${i.length}` : '-', qty: i.qty,
unit_price: i.unit_price, amount: i.amount,
}))
const d = await api<any>('post', '/api/shares', {
type: 'quote', title: q.quote_no,
payload: { customer: q.customer_name, total: q.total, note: q.note, items },
})
const url = `${location.origin}${d.url}`
await navigator.clipboard?.writeText(url).catch(() => undefined)
ElMessageBox.alert(
`报价分享链接已复制:<br/><a href="${url}" target="_blank">${url}</a><br/><br/>客户打开后免登录查看报价明细。`,
'报价分享', { dangerouslyUseHTMLString: true })
}
async function toOrder(row: any) {
await ElMessageBox.confirm(`将报价单 ${row.quote_no} 转为订单?`, '提示')
const q = await api<any>('get', `/api/quotations/${row.id}`)
+80
View File
@@ -0,0 +1,80 @@
<template>
<div class="share-page">
<header class="share-head">
<span class="logo">华门窗</span>
<span class="title">{{ data?.title || '分享详情' }}</span>
</header>
<div v-if="loading" class="center"><el-icon class="is-loading"><Loading /></el-icon></div>
<el-empty v-else-if="!data" description="分享不存在或已失效" />
<template v-else>
<!-- 渲染图/阳光房效果: 大图展示 -->
<div v-if="data.type === 'render' || data.type === 'sunny'" class="img-wrap">
<img v-if="data.payload.image" :src="data.payload.image" />
<div v-if="data.payload.desc" class="desc">{{ data.payload.desc }}</div>
</div>
<!-- 报价单: 明细表 -->
<el-card v-else-if="data.type === 'quote'" class="quote-card">
<h2 class="q-title">报价单 — {{ data.payload.customer || '客户' }}</h2>
<el-table :data="data.payload.items || []" border size="small">
<el-table-column prop="note" label="项目" min-width="140" />
<el-table-column prop="length" label="规格(mm)" width="110" />
<el-table-column prop="qty" label="数量" width="80" />
<el-table-column prop="unit_price" label="单价" width="90" />
<el-table-column prop="amount" label="金额" width="110" />
</el-table>
<div class="q-total">合计:<b>¥{{ data.payload.total }}</b></div>
<div class="q-note" v-if="data.payload.note">{{ data.payload.note }}</div>
</el-card>
<!-- 样板组合: 只读画布列表 -->
<div v-else-if="data.type === 'combine'" class="combine-list">
<div v-for="(sh, i) in data.payload.shapes || []" :key="i" class="combine-item">
<WindoorCanvas :drawing-data="sh" />
<div class="combine-name">{{ sh.titl || `窗体 ${Number(i) + 1}` }}</div>
</div>
</div>
</template>
<footer class="share-foot">本页面由华门窗云平台生成 · {{ data?.created_at?.slice(0, 10) }}</footer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api'
import WindoorCanvas from '../components/WindoorCanvas.vue'
const route = useRoute()
const data = ref<any>(null)
const loading = ref(true)
onMounted(async () => {
try {
data.value = await api<any>('get', `/api/shares/${route.params.token}`)
} catch { /* 无效分享 */ } finally { loading.value = false }
})
</script>
<style scoped>
.share-page { min-height: 100vh; background: #f2f4f7; }
.share-head { display: flex; align-items: baseline; gap: 14px; padding: 16px 22px; background: #fff;
border-bottom: 1px solid #e4e7ed; }
.logo { font-size: 20px; font-weight: 700; color: #2b3a4d; letter-spacing: 3px; }
.title { color: #606266; font-size: 14px; }
.center { display: flex; justify-content: center; padding: 80px 0; }
.img-wrap { max-width: 920px; margin: 24px auto; text-align: center; }
.img-wrap img { max-width: 100%; border-radius: 10px; box-shadow: 0 2px 14px rgba(0,0,0,.12); }
.desc { margin-top: 10px; color: #606266; font-size: 13px; }
.quote-card { max-width: 760px; margin: 24px auto; }
.q-title { margin: 0 0 12px; }
.q-total { text-align: right; margin-top: 12px; font-size: 16px; }
.q-total b { color: #e6483d; font-size: 20px; }
.q-note { margin-top: 6px; color: #909399; font-size: 12px; }
.combine-list { max-width: 920px; margin: 20px auto; display: flex; flex-direction: column; gap: 16px; }
.combine-item { background: #fff; border-radius: 10px; padding: 12px; }
.combine-item :deep(.canvas-wrap) { height: 320px; }
.combine-name { text-align: center; color: #606266; font-size: 13px; padding-top: 6px; }
.share-foot { text-align: center; color: #b0b6bf; font-size: 12px; padding: 30px 0 20px; }
</style>
+134 -1
View File
@@ -2,6 +2,8 @@
<el-card>
<div class="toolbar">
<el-button type="primary" plain @click="openCreate">新建阳光房订单</el-button>
<div style="flex:1" />
<el-button type="warning" plain @click="openDesign">阳光房设计绘图</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="order_no" label="订单号" width="160" />
@@ -34,13 +36,73 @@
<el-button type="primary" @click="save">创建</el-button>
</template>
</el-dialog>
<!-- 阳光房设计绘图(平顶/人字顶参数化 + AI 效果图) -->
<el-dialog v-model="designVisible" title="阳光房设计绘图" width="860px" top="4vh">
<el-row :gutter="16">
<el-col :span="10">
<el-form label-width="90px" size="small">
<el-form-item label="顶型">
<el-radio-group v-model="dform.roof">
<el-radio-button value="flat">平顶</el-radio-button>
<el-radio-button value="gable">人字顶</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="名称"><el-input v-model="dform.name" placeholder="如 北院阳光房" /></el-form-item>
<el-form-item label="宽度 mm"><el-input-number v-model="dform.width_mm" :min="1500" :max="20000" :step="100" /></el-form-item>
<el-form-item label="进深 mm"><el-input-number v-model="dform.depth_mm" :min="1500" :max="20000" :step="100" /></el-form-item>
<el-form-item label="檐高 mm"><el-input-number v-model="dform.height_mm" :min="2000" :max="6000" :step="50" /></el-form-item>
<el-form-item label="立柱间距"><el-input-number v-model="dform.bay_mm" :min="400" :max="3000" :step="50" /></el-form-item>
<el-form-item label="玻璃"><el-input v-model="dform.glass" /></el-form-item>
<el-form-item label="型材色">
<el-select v-model="dform.frame_color" style="width:140px">
<el-option value="深灰" /><el-option value="咖啡木纹" /><el-option value="白色" /><el-option value="黑色" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveDesign">保存设计</el-button>
<el-button type="success" plain :loading="rendering" :disabled="!lastSaved" @click="aiRender">AI 效果图</el-button>
<el-button plain :disabled="!lastSaved" @click="shareDesign">分享</el-button>
</el-form-item>
</el-form>
</el-col>
<el-col :span="14">
<div class="sun-stage">
<SunroomCanvas :roof="dform.roof" :width-mm="dform.width_mm" :depth-mm="dform.depth_mm"
:height-mm="dform.height_mm" :bay-mm="dform.bay_mm" />
</div>
<div v-if="aiImage" class="ai-wrap">
<img :src="aiImage" />
<el-button size="small" text type="primary" @click="downloadAi">下载效果图</el-button>
</div>
</el-col>
</el-row>
<el-divider content-position="left">已保存设计</el-divider>
<el-table :data="designs" size="small" border max-height="200">
<el-table-column prop="name" label="名称" min-width="110" />
<el-table-column label="顶型" width="80">
<template #default="{ row }">{{ row.roof === 'gable' ? '人字顶' : '平顶' }}</template>
</el-table-column>
<el-table-column label="尺寸" min-width="150">
<template #default="{ row }">{{ row.width_mm }} × {{ row.depth_mm }} × {{ row.height_mm }} mm</template>
</el-table-column>
<el-table-column label="操作" width="130">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="loadDesign(row)">载入</el-button>
<el-button size="small" text type="danger" @click="removeDesign(row)">删</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import { api, apiDownload } from '../api'
import SunroomCanvas from '../components/SunroomCanvas.vue'
const rows = ref<any[]>([])
const total = ref(0)
@@ -51,6 +113,13 @@ const createVisible = ref(false)
const customerOptions = ref<any[]>([])
const form = reactive<any>({ customer_id: undefined, money: 0, area: 0, note: '' })
const designVisible = ref(false)
const designs = ref<any[]>([])
const lastSaved = ref<any>(null)
const rendering = ref(false)
const aiImage = ref('')
const dform = reactive<any>({ roof: 'flat', name: '', width_mm: 4000, depth_mm: 3000, height_mm: 2800, bay_mm: 1000, glass: '双层中空钢化玻璃 5+12A+5', frame_color: '深灰' })
async function load() {
loading.value = true
try {
@@ -77,9 +146,73 @@ async function save() {
load()
}
// ---- 阳光房设计 ----
async function openDesign() {
designVisible.value = true
designs.value = (await api<any[]>('get', '/api/sunroom/designs')) ?? []
}
function loadDesign(row: any) {
Object.assign(dform, {
roof: row.roof, name: row.name, width_mm: row.width_mm, depth_mm: row.depth_mm,
height_mm: row.height_mm, bay_mm: row.bay_mm,
glass: row.params?.glass ?? dform.glass, frame_color: row.params?.frame_color ?? '深灰',
})
lastSaved.value = row
aiImage.value = ''
}
async function saveDesign() {
lastSaved.value = await api<any>('post', '/api/sunroom/designs', dform)
ElMessage.success('设计已保存')
designs.value = (await api<any[]>('get', '/api/sunroom/designs')) ?? []
}
async function aiRender() {
rendering.value = true
aiImage.value = ''
try {
const d = await api<any>('post', '/api/ai-render/generate', {
template_id: null, mode: 'quick',
style: 'modern', time: 'day',
scene: dform.roof === 'gable' ? 'sunroom-gable' : 'sunroom-flat',
drawing_data: { sm: [], _sunroom: { width_mm: dform.width_mm, depth_mm: dform.depth_mm, height_mm: dform.height_mm, bay_mm: dform.bay_mm } },
}, { timeout: 200000 })
aiImage.value = `data:image/png;base64,${d.image_b64}`
ElMessage.success('AI 效果图已生成')
} catch { /* 拦截器已提示 */ } finally { rendering.value = false }
}
function downloadAi() {
if (!aiImage.value) return
const a = document.createElement('a')
a.href = aiImage.value
a.download = `阳光房效果图_${dform.name || '设计'}.png`
a.click()
}
async function shareDesign() {
const d = await api<any>('post', '/api/shares', {
type: 'sunny', title: dform.name || '阳光房设计',
payload: { image: aiImage.value || '', desc: `${dform.roof === 'gable' ? '人字顶' : '平顶'} · ${dform.width_mm}×${dform.depth_mm}×${dform.height_mm}mm · ${dform.glass}` },
})
const url = `${location.origin}${d.url}`
await navigator.clipboard?.writeText(url).catch(() => undefined)
ElMessage.success(`分享链接已复制: ${url}`)
}
async function removeDesign(row: any) {
await api('delete', `/api/sunroom/designs/${row.id}`)
ElMessage.success('已删除')
designs.value = (await api<any[]>('get', '/api/sunroom/designs')) ?? []
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.sun-stage { border: 1px solid #e4e7ed; border-radius: 8px; padding: 8px; height: 360px; overflow: hidden; }
.ai-wrap { margin-top: 10px; text-align: center; }
.ai-wrap img { max-width: 100%; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.12); }
</style>
+75 -10
View File
@@ -2,18 +2,56 @@
<div>
<el-card>
<div class="toolbar">
<el-radio-group v-model="tab" size="small" @change="load">
<el-radio-group v-model="tab" size="small" @change="tabChange">
<el-radio-button value="official">官方样板</el-radio-button>
<el-radio-button value="mine">我的样板</el-radio-button>
<el-radio-button value="ai">AI 推荐</el-radio-button>
</el-radio-group>
<el-tree-select v-model="catelogId" :data="catelogs" node-key="id"
:props="{ label: 'name', children: 'children' }" check-strictly default-expand-all
clearable style="width:170px" placeholder="全部分类" @change="load" />
<el-input v-model="name" placeholder="模板名称" clearable style="width:200px" @keyup.enter="load" />
<el-button type="primary" @click="load">查询</el-button>
<div style="flex:1" />
<span class="count">共 {{ total }} 套{{ tab === 'mine' ? '我的' : '官方' }}样板</span>
<template v-if="tab !== 'ai'">
<el-tree-select v-model="catelogId" :data="catelogs" node-key="id"
:props="{ label: 'name', children: 'children' }" check-strictly default-expand-all
clearable style="width:170px" placeholder="全部分类" @change="load" />
<el-input v-model="name" placeholder="模板名称" clearable style="width:200px" @keyup.enter="load" />
<el-button type="primary" @click="load">查询</el-button>
<div style="flex:1" />
<span class="count">共 {{ total }} 套{{ tab === 'mine' ? '我的' : '官方' }}样板</span>
</template>
</div>
<!-- AI 智能推荐(ai_recommend 语义:洞口+场景+需求 → 可解释推荐) -->
<div v-if="tab === 'ai'" v-loading="recLoading" class="rec-panel">
<div class="rec-form">
<span>洞口</span>
<el-input-number v-model="rec.width_mm" :min="300" :max="20000" :step="100" controls-position="right" style="width:120px" />
<span>×</span>
<el-input-number v-model="rec.height_mm" :min="300" :max="20000" :step="100" controls-position="right" style="width:120px" />
<span>mm</span>
<el-select v-model="rec.room" style="width:110px">
<el-option value="living" label="客厅" /><el-option value="bedroom" label="卧室" />
<el-option value="balcony" label="阳台" /><el-option value="kitchen" label="厨房" />
<el-option value="bath" label="卫浴" /><el-option value="shop" label="商铺" />
</el-select>
<el-select v-model="rec.needs" multiple collapse-tags style="min-width:200px" placeholder="需求标签">
<el-option value="ventilation" label="通风" /><el-option value="light" label="采光" />
<el-option value="sound" label="隔音" /><el-option value="safe" label="安全防蚊" />
<el-option value="view" label="景观" /><el-option value="door" label="通行" />
</el-select>
<el-button type="primary" :loading="recLoading" @click="doRecommend">✦ 智能推荐</el-button>
</div>
<el-empty v-if="!recItems.length && !recLoading" description="输入洞口尺寸与需求,AI 从 254 套样板中推荐最匹配窗型" />
<div v-for="it in recItems" :key="it.id" class="rec-item" @click="preview(it)">
<el-image :src="it.pic_url" fit="contain" class="rec-pic" />
<div class="rec-info">
<b>{{ it.name || `样板#${it.id}` }}</b>
<el-tag size="small" type="success" effect="plain">匹配 {{ it.score }}</el-tag>
<div class="rec-reasons">
<el-tag v-for="r in it.reasons" :key="r" size="small" effect="plain">{{ r }}</el-tag>
</div>
</div>
</div>
</div>
<template v-else>
<div v-loading="loading" class="grid">
<div v-for="t in rows" :key="t.id" class="item" @click="preview(t)">
<el-badge :value="t.times ? `${t.times}次` : ''" :hidden="!t.times" class="bdg">
@@ -26,6 +64,7 @@
</div>
<el-pagination style="margin-top:12px; justify-content:center" background layout="total, prev, pager, next"
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" />
</template>
</el-card>
<el-dialog v-model="previewVisible" :title="current?.name || `样板#${current?.id}`" width="72%" top="4vh">
@@ -58,7 +97,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { api } from '../api'
@@ -81,9 +120,27 @@ const editing = ref(false)
const dirty = ref(false)
const editRef = ref()
const liveTotal = ref<number | null>(null)
const tab = ref<'official' | 'mine'>('official')
const tab = ref<'official' | 'mine' | 'ai'>('official')
let quoteTimer: number | undefined
// ---- AI 智能推荐 ----
const recLoading = ref(false)
const recItems = ref<any[]>([])
const rec = reactive({ width_mm: 2400, height_mm: 1500, room: 'living', needs: [] as string[] })
function tabChange() {
if (tab.value !== 'ai') load()
}
async function doRecommend() {
recLoading.value = true
try {
const d = await api<any>('post', '/api/ai-recommend', rec)
recItems.value = d.items
if (!d.items?.length) ElMessage.info('未找到匹配样板')
} finally { recLoading.value = false }
}
// 画布变化(改尺寸/加分格)→ 防抖自动重算报价
function onCanvasChange() {
dirty.value = true
@@ -162,4 +219,12 @@ onMounted(async () => {
.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; }
.rec-panel { min-height: 300px; }
.rec-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.rec-item { display: flex; gap: 14px; align-items: center; border: 1px solid #e4e7ed; border-radius: 10px;
padding: 10px 14px; margin-bottom: 10px; cursor: pointer; background: #fff; transition: box-shadow .2s; }
.rec-item:hover { box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.rec-pic { width: 130px; height: 90px; flex: none; }
.rec-info { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.rec-reasons { display: flex; gap: 6px; flex-wrap: wrap; }
</style>