feat: P1批次1 设计域(AI推荐/分享体系/阳光房绘图/量尺标注/逐格指定编辑)
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user