feat: v1.0 AI炫图工作台上线+业务做实+按需裁剪+主题精致化
- AI炫图: 自研程序化场景合成引擎(风格×时间×场景, 家具剪影/光束/暗角, seed构图微差), 阶段化生成动画+四方案画廊+PNG下载, 引擎抽为共享模块 - 业务做实: 报价接入产品价格本(use_price_book), 注册邀请码+返利真实计数, 报价单转订单携带图纸 - 按需裁剪: 下线短信验证码登录/支付收银, 商城改为功能总览 - 主题精致化: 品牌色设计变量+Element Plus主题覆盖+视觉升级 - 全接口回归通过, 移除项405确认
This commit is contained in:
+37
-1
@@ -3,6 +3,42 @@
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* ===== 全局设计变量 ===== */
|
||||
:root {
|
||||
--brand: #3f5f80;
|
||||
--brand-2: #6b8cae;
|
||||
--accent: #5b3fd4;
|
||||
--danger: #e6483d;
|
||||
--radius: 10px;
|
||||
--shadow-card: 0 2px 12px rgba(30, 45, 65, 0.08);
|
||||
}
|
||||
/* Element Plus 主题覆盖 */
|
||||
:root {
|
||||
--el-color-primary: #3f5f80;
|
||||
--el-color-primary-light-3: #66819f;
|
||||
--el-color-primary-light-5: #8fa3ba;
|
||||
--el-color-primary-light-7: #b9c6d4;
|
||||
--el-color-primary-light-8: #ccd6e0;
|
||||
--el-color-primary-light-9: #e5ebf1;
|
||||
--el-color-primary-dark-2: #324c66;
|
||||
--el-border-radius-base: 8px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* 卡片统一圆角与悬停 */
|
||||
.el-card { border-radius: var(--radius); border: none; box-shadow: var(--shadow-card) !important; }
|
||||
.el-button { border-radius: 8px; }
|
||||
/* 表头底色 */
|
||||
.el-table th.el-table__cell { background: #f7f9fb; }
|
||||
/* 页面切换动效 */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity .18s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: #c9d2db; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a9b6c2; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<canvas ref="cvRef" class="ai-canvas" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** AI 炫图画布组件:薄封装,渲染引擎见 variantRenderer.ts */
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { renderAI } from './variantRenderer'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
drawingData: string | object
|
||||
style?: 'modern' | 'chinese' | 'european' | 'nordic'
|
||||
time?: 'day' | 'dusk' | 'night'
|
||||
scene?: 'indoor' | 'outdoor' | 'studio'
|
||||
seed?: number
|
||||
}>(), { style: 'modern', time: 'day', scene: 'indoor', seed: 1 })
|
||||
|
||||
const cvRef = ref<HTMLCanvasElement>()
|
||||
|
||||
function render() {
|
||||
if (cvRef.value && props.drawingData) renderAI(cvRef.value, props.drawingData, props.style, props.time, props.scene, props.seed)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
watch(() => [props.drawingData, props.style, props.time, props.scene, props.seed], render, { immediate: true })
|
||||
window.addEventListener('resize', render)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-canvas { width: 100%; height: 100%; display: block; border-radius: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* AI 炫图渲染引擎(自研程序化场景合成,纯函数)
|
||||
* renderAI(canvas, drawingData, style, time, scene, seed)
|
||||
* 墙面/地板/家具剪影/天空/玻璃光影/光束/暗角;seed 决定构图微差。
|
||||
*/
|
||||
import { bboxOf, parseDrawing, regionPoints, replayShape, type DrawingData } from '../types/drawing'
|
||||
|
||||
export interface Palette {
|
||||
wall: [string, string]; floor: [string, string]; sky: [string, string, string]
|
||||
frame: string; accent: string; light: string
|
||||
}
|
||||
|
||||
export const PRESETS: Record<string, Palette> = {
|
||||
modern: { wall: ['#f6f2ea', '#e7e0d4'], floor: ['#c9a97a', '#a98a5f'], sky: ['#bcd9f0', '#e8f2fa', '#f8fbff'], frame: '#3a3e43', accent: '#7a8b6f', light: '#fff7e0' },
|
||||
chinese: { wall: ['#f3e9d7', '#e5d5ba'], floor: ['#8a4a32', '#6d3826'], sky: ['#c2d8ea', '#e6eef5', '#f5f8fb'], frame: '#7a3b28', accent: '#9a6b4f', light: '#ffedc9' },
|
||||
european: { wall: ['#f7f1e3', '#ece0c8'], floor: ['#b08d5f', '#8f6f4a'], sky: ['#aecdea', '#dbe8f5', '#f2f7fd'], frame: '#e8e2d2', accent: '#c9b28a', light: '#fff3d6' },
|
||||
nordic: { wall: ['#f2f3f1', '#e2e5e1'], floor: ['#d9c6a5', '#bfa87f'], sky: ['#c5dcf2', '#e9f4fc', '#fafdff'], frame: '#f4f2ec', accent: '#8fae9c', light: '#fdf9ec' },
|
||||
}
|
||||
|
||||
const TIME_SKY: Record<string, Record<string, [string, string, string]>> = {
|
||||
day: {},
|
||||
dusk: { indoor: ['#f0a56a', '#f6c98f', '#fde8c8'], outdoor: ['#e88a4f', '#f4b878', '#fce4bd'], studio: ['#e8b585', '#f4d3a8', '#fdf0d8'] },
|
||||
night: { indoor: ['#1d2b45', '#2c3e5f', '#48597a'], outdoor: ['#152238', '#22344f', '#3a4d6e'], studio: ['#26303f', '#39465a', '#525f74'] },
|
||||
}
|
||||
|
||||
function rng(seed: number) {
|
||||
let s = seed % 2147483647
|
||||
if (s <= 0) s += 2147483646
|
||||
return () => (s = s * 16807 % 2147483647) / 2147483647
|
||||
}
|
||||
|
||||
export function renderAI(
|
||||
cv: HTMLCanvasElement, drawingData: string | object,
|
||||
style = 'modern', time = 'day', scene = 'indoor', seed = 1,
|
||||
): void {
|
||||
const wrap = cv.parentElement
|
||||
if (!wrap) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
cv.width = wrap.clientWidth * dpr
|
||||
cv.height = wrap.clientHeight * dpr
|
||||
const ctx = cv.getContext('2d')!
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
const W = wrap.clientWidth, H = wrap.clientHeight
|
||||
if (!W || !H) return
|
||||
|
||||
const pal = PRESETS[style] ?? PRESETS.modern
|
||||
const rand = rng(seed * 7919 + 13)
|
||||
|
||||
// 背景(天空)
|
||||
const sky = TIME_SKY[time]?.[scene] ?? pal.sky
|
||||
const bg = ctx.createLinearGradient(0, 0, 0, H)
|
||||
bg.addColorStop(0, sky[0]); bg.addColorStop(0.6, sky[1]); bg.addColorStop(1, sky[2])
|
||||
ctx.fillStyle = bg
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
// 墙面 + 地板
|
||||
const floorY = H * 0.68
|
||||
const wall = ctx.createLinearGradient(0, 0, 0, floorY)
|
||||
wall.addColorStop(0, pal.wall[0]); wall.addColorStop(1, pal.wall[1])
|
||||
ctx.fillStyle = wall
|
||||
ctx.fillRect(0, 0, W, floorY)
|
||||
const floor = ctx.createLinearGradient(0, floorY, 0, H)
|
||||
floor.addColorStop(0, pal.floor[0]); floor.addColorStop(1, pal.floor[1])
|
||||
ctx.fillStyle = floor
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, floorY); ctx.lineTo(W, floorY)
|
||||
ctx.lineTo(W * 0.92, H); ctx.lineTo(W * 0.08, H)
|
||||
ctx.closePath(); ctx.fill()
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.08)'
|
||||
ctx.lineWidth = 1
|
||||
for (let i = 1; i < 8; i++) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(W * (i / 8), floorY)
|
||||
ctx.lineTo(W * (0.08 + 0.84 * (i / 8)), H)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.10)'
|
||||
ctx.fillRect(0, floorY - 6, W, 6)
|
||||
|
||||
// 窗型几何
|
||||
let dd: DrawingData
|
||||
try { dd = parseDrawing(drawingData) } catch { return }
|
||||
if (!dd?.sm?.length) return
|
||||
const pts = dd.sm.flatMap(s => replayShape(s).regions.flat().flatMap(seg => [seg.ps, seg.pe]))
|
||||
if (!pts.length) return
|
||||
const gb = bboxOf(pts)
|
||||
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
|
||||
const scale = Math.min(W * 0.56 / gw, H * 0.52 / gh)
|
||||
const ox = (W - gw * scale) / 2
|
||||
const oy = floorY - gh * scale - H * 0.12
|
||||
const tx = (x: number) => ox + (x - gb.x0) * scale
|
||||
const ty = (y: number) => oy + (gb.y1 - y) * scale
|
||||
|
||||
// 窗洞
|
||||
ctx.save()
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.4)'
|
||||
ctx.shadowBlur = 30
|
||||
ctx.fillStyle = time === 'night' ? '#16233a' : sky[1]
|
||||
ctx.fillRect(tx(gb.x0) - 10, ty(gb.y1) - 10, gw * scale + 20, gh * scale + 20)
|
||||
ctx.restore()
|
||||
|
||||
// 玻璃
|
||||
for (const shape of dd.sm) {
|
||||
for (const region of replayShape(shape).regions) {
|
||||
const rpts = regionPoints(region)
|
||||
const bb = bboxOf(rpts)
|
||||
const gx = tx(bb.x0), gy = ty(bb.y1), w = (bb.x1 - bb.x0) * scale, h = (bb.y1 - bb.y0) * scale
|
||||
const g = ctx.createLinearGradient(gx, gy, gx, gy + h)
|
||||
g.addColorStop(0, sky[0]); g.addColorStop(0.55, sky[1]); g.addColorStop(1, sky[2])
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
rpts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
|
||||
ctx.closePath()
|
||||
ctx.clip()
|
||||
ctx.fillStyle = g
|
||||
ctx.fillRect(gx, gy, w, h)
|
||||
// 远景剪影
|
||||
ctx.fillStyle = time === 'night' ? 'rgba(10,18,32,0.55)' : 'rgba(90,110,130,0.28)'
|
||||
let x = gx
|
||||
while (x < gx + w) {
|
||||
const bw = 14 + rand() * 26, bh = (10 + rand() * 0.5 * h) * (time === 'night' ? 0.7 : 0.45)
|
||||
ctx.fillRect(x, gy + h - bh, bw, bh)
|
||||
x += bw + 4 + rand() * 10
|
||||
}
|
||||
// 光束
|
||||
if (time !== 'dusk') {
|
||||
const ray = ctx.createLinearGradient(gx, gy, gx + w * 0.7, gy + h)
|
||||
ray.addColorStop(0, time === 'day' ? 'rgba(255,244,200,0.5)' : 'rgba(120,160,255,0.25)')
|
||||
ray.addColorStop(1, 'rgba(255,255,255,0)')
|
||||
ctx.fillStyle = ray
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(gx, gy); ctx.lineTo(gx + w * 0.55, gy)
|
||||
ctx.lineTo(gx + w * 0.2, gy + h); ctx.lineTo(gx - w * 0.05, gy + h)
|
||||
ctx.closePath(); ctx.fill()
|
||||
}
|
||||
// 反光斜带
|
||||
ctx.globalAlpha = 0.14
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(gx + w * 0.12, gy); ctx.lineTo(gx + w * 0.3, gy)
|
||||
ctx.lineTo(gx - w * 0.05, gy + h); ctx.lineTo(gx - w * 0.22, gy + h)
|
||||
ctx.closePath(); ctx.fill()
|
||||
ctx.globalAlpha = 1
|
||||
ctx.restore()
|
||||
ctx.strokeStyle = pal.frame
|
||||
ctx.lineWidth = 2.4
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
// 外框
|
||||
ctx.strokeStyle = pal.frame
|
||||
ctx.lineWidth = 7
|
||||
ctx.strokeRect(tx(gb.x0) - 4, ty(gb.y1) - 4, gw * scale + 8, gh * scale + 8)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.35)'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.strokeRect(tx(gb.x0) - 1, ty(gb.y1) - 1, gw * scale + 2, gh * scale + 2)
|
||||
|
||||
// 地板光束
|
||||
if (time !== 'dusk') {
|
||||
const ray = ctx.createLinearGradient(tx(gb.x0), floorY, tx(gb.x0) + gw * scale * 0.4, H)
|
||||
ray.addColorStop(0, time === 'day' ? 'rgba(255,240,190,0.30)' : 'rgba(140,170,255,0.12)')
|
||||
ray.addColorStop(1, 'rgba(255,255,255,0)')
|
||||
ctx.fillStyle = ray
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(tx(gb.x0), oy); ctx.lineTo(tx(gb.x1), oy)
|
||||
ctx.lineTo(tx(gb.x1) + W * 0.12, H); ctx.lineTo(tx(gb.x0) - W * 0.05, H)
|
||||
ctx.closePath(); ctx.fill()
|
||||
}
|
||||
|
||||
// 家具剪影
|
||||
const sil = time === 'night' ? 'rgba(16,24,40,0.82)' : 'rgba(60,70,80,0.55)'
|
||||
ctx.fillStyle = sil
|
||||
const sy = floorY - H * 0.02
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(W * 0.06, sy - H * 0.10, W * 0.16, H * 0.10, 8)
|
||||
ctx.roundRect(W * 0.05, sy - H * 0.16, W * 0.035, H * 0.10, 6)
|
||||
ctx.fill()
|
||||
const px = W * 0.88, py = floorY + 4
|
||||
ctx.strokeStyle = 'rgba(70,60,45,0.7)'
|
||||
ctx.lineWidth = 4
|
||||
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, py - H * 0.05); ctx.stroke()
|
||||
ctx.fillStyle = pal.accent
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = -Math.PI / 2 + (i - 2.5) * 0.45 + rand() * 0.1
|
||||
const L = H * (0.07 + rand() * 0.045)
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(px + Math.cos(a) * L * 0.5, py - H * 0.05 + Math.sin(a) * L * 0.5, L * 0.34, L * 0.13, a, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
if (style === 'chinese') {
|
||||
ctx.fillStyle = 'rgba(150,60,45,0.75)'
|
||||
ctx.fillRect(W * 0.14, H * 0.06, W * 0.05, H * 0.2)
|
||||
ctx.strokeStyle = 'rgba(60,40,30,0.8)'; ctx.lineWidth = 3
|
||||
ctx.strokeRect(W * 0.14, H * 0.06, W * 0.05, H * 0.2)
|
||||
} else if (style === 'european') {
|
||||
ctx.fillStyle = 'rgba(214,196,158,0.85)'
|
||||
ctx.fillRect(W * 0.03, 0, W * 0.06, floorY * 0.9)
|
||||
ctx.fillRect(W * 0.91, 0, W * 0.06, floorY * 0.9)
|
||||
} else if (style === 'nordic') {
|
||||
ctx.fillStyle = 'rgba(200,214,206,0.6)'
|
||||
ctx.beginPath(); ctx.roundRect(W * 0.72, floorY - H * 0.08, W * 0.1, H * 0.08, 6); ctx.fill()
|
||||
}
|
||||
|
||||
// 暗角
|
||||
const vig = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.45, W / 2, H / 2, Math.max(W, H) * 0.75)
|
||||
vig.addColorStop(0, 'rgba(0,0,0,0)')
|
||||
vig.addColorStop(1, time === 'night' ? 'rgba(0,0,20,0.45)' : 'rgba(0,0,0,0.22)')
|
||||
ctx.fillStyle = vig
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const router = createRouter({
|
||||
{ path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } },
|
||||
{ path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
|
||||
{ path: 'rendering', component: () => import('../views/RenderView.vue'), meta: { title: '效果图渲染' } },
|
||||
{ path: 'ai_render', component: () => import('../views/AIRenderView.vue'), meta: { title: 'AI 炫图' } },
|
||||
{ path: 'threed', component: () => import('../views/RenderView.vue'), meta: { title: '3D 展示' } },
|
||||
{ path: 'printer', component: () => import('../views/PrinterView.vue'), meta: { title: '打印中心' } },
|
||||
{ path: 'cut_solution', component: () => import('../views/CutSolutionView.vue'), meta: { title: '下料方案' } },
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="head">
|
||||
<span class="brand">✦ AI 炫图</span>
|
||||
<span class="sub">选择样板 → 设定风格 → 一键生成多方案效果图</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="controls">
|
||||
<el-select v-model="templateId" filterable placeholder="选择样板" style="width:220px" @change="loadTemplate">
|
||||
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
|
||||
</el-select>
|
||||
<el-select v-model="form.ai_style" style="width:130px">
|
||||
<el-option value="modern" label="现代简约" /><el-option value="chinese" label="新中式" />
|
||||
<el-option value="european" label="欧式" /><el-option value="nordic" label="北欧" />
|
||||
</el-select>
|
||||
<el-select v-model="form.ai_time" style="width:110px">
|
||||
<el-option value="day" label="日景" /><el-option value="dusk" label="黄昏" /><el-option value="night" label="夜景" />
|
||||
</el-select>
|
||||
<el-select v-model="form.scene" style="width:110px">
|
||||
<el-option value="indoor" label="室内" /><el-option value="outdoor" label="室外" /><el-option value="studio" label="影棚" />
|
||||
</el-select>
|
||||
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generate">
|
||||
{{ generating ? '生成中…' : '✦ 生成 AI 炫图' }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
|
||||
:format="() => stageText" />
|
||||
|
||||
<div v-if="drawing && !variants.length && !generating" class="preview">
|
||||
<AIRenderCanvas :drawing-data="drawing" :style="form.ai_style" :time="form.ai_time"
|
||||
:scene="form.scene" :seed="form.seed" />
|
||||
</div>
|
||||
|
||||
<div v-if="variants.length" class="gallery">
|
||||
<div v-for="(v, i) in variants" :key="i" class="shot" :class="{ pick: pickIdx === i }" @click="pickIdx = i">
|
||||
<canvas :ref="el => setVariantRef(el, i)" class="shot-cv" />
|
||||
<div class="cap">{{ v.label }}</div>
|
||||
<el-icon class="dl" @click.stop="download(i)"><Download /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!drawing" description="请先选择样板" />
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import AIRenderCanvas from '../components/AIRenderCanvas.vue'
|
||||
|
||||
const templates = ref<any[]>([])
|
||||
const templateId = ref<number | undefined>()
|
||||
const drawing = ref('')
|
||||
const generating = ref(false)
|
||||
const progress = ref(0)
|
||||
const stageText = ref('')
|
||||
const variants = ref<{ label: string; style: string; time: string; seed: number }[]>([])
|
||||
const pickIdx = ref(0)
|
||||
const form = reactive({ ai_style: 'modern', ai_time: 'day', scene: 'indoor', seed: 1 })
|
||||
|
||||
const variantCvs: (HTMLCanvasElement | null)[] = []
|
||||
function setVariantRef(el: any, i: number) { variantCvs[i] = el as HTMLCanvasElement }
|
||||
|
||||
const STAGES = ['解析窗型结构', '合成场景光照', '匹配材质纹理', '生成效果图']
|
||||
|
||||
async function loadTemplate(tid: number | undefined) {
|
||||
drawing.value = ''
|
||||
variants.value = []
|
||||
if (!tid) return
|
||||
const t = await api<any>('get', `/api/windoor/templates/${tid}`)
|
||||
drawing.value = t.drawing_data
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
generating.value = true
|
||||
variants.value = []
|
||||
// 阶段化进度(本地程序化渲染,进度为演示动画)
|
||||
for (let s = 0; s < STAGES.length; s++) {
|
||||
stageText.value = STAGES[s]
|
||||
for (let p = 0; p <= 25; p += 5) {
|
||||
progress.value = Math.min(100, s * 25 + p)
|
||||
await new Promise(r => setTimeout(r, 24))
|
||||
}
|
||||
}
|
||||
progress.value = 100
|
||||
// 生成 4 个方案:风格×时间组合 + seed 微差
|
||||
variants.value = [
|
||||
{ label: `${styleName(form.ai_style)} · ${timeName(form.ai_time)}`, style: form.ai_style, time: form.ai_time, seed: form.seed },
|
||||
{ label: `${styleName(form.ai_style)} · 日景`, style: form.ai_style, time: 'day', seed: form.seed + 11 },
|
||||
{ label: `${styleName(form.ai_style)} · 黄昏`, style: form.ai_style, time: 'dusk', seed: form.seed + 23 },
|
||||
{ label: '新中式 · 夜景', style: 'chinese', time: 'night', seed: form.seed + 37 },
|
||||
]
|
||||
pickIdx.value = 0
|
||||
generating.value = false
|
||||
await nextFrames()
|
||||
// 渲染画廊各画布(共享渲染引擎)
|
||||
const { renderAI } = await import('../components/variantRenderer')
|
||||
variants.value.forEach((v, i) => {
|
||||
const cv = variantCvs[i]
|
||||
if (cv) renderAI(cv, drawing.value, v.style, v.time, form.scene, v.seed)
|
||||
})
|
||||
ElMessage.success('已生成 4 套效果图')
|
||||
}
|
||||
|
||||
function nextFrames(): Promise<void> {
|
||||
return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r())))
|
||||
}
|
||||
|
||||
function styleName(s: string) { return ({ modern: '现代简约', chinese: '新中式', european: '欧式', nordic: '北欧' } as Record<string, string>)[s] ?? s }
|
||||
function timeName(t: string) { return ({ day: '日景', dusk: '黄昏', night: '夜景' } as Record<string, string>)[t] ?? t }
|
||||
|
||||
function download(i: number) {
|
||||
const cv = variantCvs[i]
|
||||
if (!cv) return
|
||||
const a = document.createElement('a')
|
||||
a.href = cv.toDataURL('image/png')
|
||||
a.download = `AI炫图_${i + 1}.png`
|
||||
a.click()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
templates.value = await api<any>('get', '/api/windoor/templates?page=1&page_size=254')
|
||||
Object.assign(form, { ai_style: form.ai_style, ai_time: form.ai_time, scene: form.scene },
|
||||
await api<any>('get', '/api/rendering-info'))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.brand { font-size: 17px; font-weight: 700; color: #5b3fd4; }
|
||||
.sub { color: #909399; font-size: 12px; }
|
||||
.controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.preview { height: 58vh; }
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
|
||||
.shot { position: relative; border: 2px solid transparent; border-radius: 10px; overflow: hidden; cursor: pointer;
|
||||
height: 200px; transition: border-color .2s, transform .2s; }
|
||||
.shot:hover { transform: translateY(-2px); }
|
||||
.shot.pick { border-color: #5b3fd4; }
|
||||
.shot-cv { width: 100%; height: 100%; display: block; }
|
||||
.cap { position: absolute; left: 8px; bottom: 6px; color: #fff; font-size: 12px;
|
||||
background: rgba(0,0,0,0.45); padding: 2px 8px; border-radius: 10px; }
|
||||
.dl { position: absolute; right: 8px; top: 8px; color: #fff; background: rgba(0,0,0,0.45);
|
||||
border-radius: 6px; padding: 4px; cursor: pointer; }
|
||||
</style>
|
||||
@@ -19,6 +19,7 @@
|
||||
<el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item>
|
||||
<el-menu-item index="/cut_solution"><el-icon><SetUp /></el-icon>下料方案</el-menu-item>
|
||||
<el-menu-item index="/rendering"><el-icon><Picture /></el-icon>效果图渲染</el-menu-item>
|
||||
<el-menu-item index="/ai_render"><el-icon><MagicStick /></el-icon>AI 炫图</el-menu-item>
|
||||
<el-menu-item index="/threed"><el-icon><View /></el-icon>3D 展示</el-menu-item>
|
||||
<el-menu-item index="/printer"><el-icon><Printer /></el-icon>打印中心</el-menu-item>
|
||||
<el-menu-item index="/devices"><el-icon><Cpu /></el-icon>设备管理</el-menu-item>
|
||||
|
||||
@@ -7,47 +7,40 @@
|
||||
<el-tab-pane label="登录" name="login">
|
||||
<el-form :model="loginForm" @keyup.enter="doLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="loginForm.phone_number" placeholder="手机号" size="large" />
|
||||
<el-input v-model="loginForm.phone_number" placeholder="手机号" size="large">
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password />
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password>
|
||||
<template #prefix><el-icon><Lock /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">登 录</el-button>
|
||||
<el-button text type="primary" style="width:100%; margin: 8px 0 0" @click="tab = 'code'">验证码登录</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="验证码登录" name="code">
|
||||
<el-form :model="codeForm" @keyup.enter="doCodeLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="codeForm.phone_number" placeholder="手机号" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div style="display:flex; gap:8px; width:100%">
|
||||
<el-input v-model="codeForm.code" placeholder="验证码" size="large" style="flex:1" />
|
||||
<el-button size="large" :disabled="countdown > 0" @click="sendCode">
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doCodeLogin">登 录</el-button>
|
||||
<p class="sms-tip" v-if="mockCode">开发提示:mock 验证码 {{ mockCode }}(见服务端日志)</p>
|
||||
<el-button type="primary" size="large" class="w-full" :loading="loading" @click="doLogin">登 录</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="注册" name="register">
|
||||
<el-form :model="regForm" @keyup.enter="doRegister">
|
||||
<el-form-item>
|
||||
<el-input v-model="regForm.phone_number" placeholder="手机号" size="large" />
|
||||
<el-input v-model="regForm.phone_number" placeholder="手机号" size="large">
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="regForm.password" type="password" placeholder="设置密码(至少4位)" size="large" show-password />
|
||||
<el-input v-model="regForm.password" type="password" placeholder="设置密码(至少4位)" size="large" show-password>
|
||||
<template #prefix><el-icon><Lock /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="regForm.invite_code" placeholder="邀请码(可选)" size="large" />
|
||||
<el-input v-model="regForm.invite_code" placeholder="邀请码(可选,如 HMC000001)" size="large">
|
||||
<template #prefix><el-icon><Promotion /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doRegister">注 册</el-button>
|
||||
<el-button type="primary" size="large" class="w-full" :loading="loading" @click="doRegister">注 册</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<p class="tip">开发账号:13800000000 / 123456 | 忘记密码请联系管理员</p>
|
||||
<p class="tip">开发账号:13800000000 / 123456</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -65,33 +58,6 @@ const loading = ref(false)
|
||||
const tab = ref('login')
|
||||
const loginForm = reactive({ phone_number: '', password: '' })
|
||||
const regForm = reactive({ phone_number: '', password: '', invite_code: '' })
|
||||
const codeForm = reactive({ phone_number: '', code: '' })
|
||||
const countdown = ref(0)
|
||||
const mockCode = ref('')
|
||||
let cdTimer: number | undefined
|
||||
|
||||
async function sendCode() {
|
||||
if (!codeForm.phone_number) return ElMessage.warning('请输入手机号')
|
||||
await api('post', '/api/verification-code-new', { phone_number: codeForm.phone_number, scene: 'login' })
|
||||
ElMessage.success('验证码已发送(开发环境见服务端日志)')
|
||||
// 开发环境后端走 mock 通道:尝试从响应头拿不到就提示看日志
|
||||
mockCode.value = ''
|
||||
countdown.value = 60
|
||||
cdTimer = window.setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) clearInterval(cdTimer)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function doCodeLogin() {
|
||||
if (!codeForm.phone_number || !codeForm.code) return ElMessage.warning('请输入手机号和验证码')
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await api<{ token: string; user: { nickname: string } }>('post', '/api/code-login', codeForm)
|
||||
auth.setSession(d.token, d.user?.nickname ?? '')
|
||||
router.push('/dashboard')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
if (!loginForm.phone_number || !loginForm.password) return ElMessage.warning('请输入手机号和密码')
|
||||
@@ -111,6 +77,7 @@ async function doRegister() {
|
||||
try {
|
||||
const d = await api<{ token: string; user_id: number }>('post', '/api/auth/register', {
|
||||
phone_number: regForm.phone_number, password: regForm.password,
|
||||
invite_code: regForm.invite_code,
|
||||
})
|
||||
auth.setSession(d.token, regForm.phone_number.slice(-4))
|
||||
ElMessage.success('注册成功')
|
||||
@@ -123,9 +90,14 @@ async function doRegister() {
|
||||
|
||||
<style scoped>
|
||||
.login-page { height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #35506b 0%, #6b8cae 100%); }
|
||||
.login-card { width: 380px; text-align: center; padding: 12px 8px; }
|
||||
h2 { margin: 8px 0 4px; color: #303133; }
|
||||
.sub { color: #909399; font-size: 13px; margin: 0 0 12px; }
|
||||
background:
|
||||
radial-gradient(1200px 600px at 15% 10%, rgba(107,140,174,0.55), transparent),
|
||||
radial-gradient(1000px 500px at 85% 90%, rgba(91,63,212,0.35), transparent),
|
||||
linear-gradient(135deg, #2b3a4d 0%, #46617f 60%, #5b74a0 100%); }
|
||||
.login-card { width: 400px; padding: 16px 12px; border-radius: 14px;
|
||||
box-shadow: 0 20px 60px rgba(20, 35, 55, 0.45); }
|
||||
h2 { margin: 8px 0 4px; color: #303133; letter-spacing: 2px; }
|
||||
.sub { color: #909399; font-size: 13px; margin: 0 0 14px; }
|
||||
.tip { color: #c0c4cc; font-size: 12px; margin-top: 14px; }
|
||||
.w-full { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -4,34 +4,29 @@
|
||||
<el-card shadow="hover" class="module">
|
||||
<div class="m-head">
|
||||
<span class="m-name">{{ m.name }}</span>
|
||||
<el-tag size="small" :type="m.enabled ? 'success' : 'info'">{{ m.enabled ? '已开通' : '未开通' }}</el-tag>
|
||||
<el-tag size="small" type="success">已包含</el-tag>
|
||||
</div>
|
||||
<p class="m-desc">{{ m.desc }}</p>
|
||||
<div class="m-foot">
|
||||
<span class="m-price">{{ m.price > 0 ? `¥${m.price}/年` : '免费' }}</span>
|
||||
<el-button size="small" :type="m.enabled ? 'default' : 'primary'" :disabled="m.enabled"
|
||||
@click="buy(m)">{{ m.enabled ? '已开通' : '立即购买' }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-top:8px"
|
||||
title="以上功能在当前版本中全部开放使用;按需开通的订阅方案已预留(HMC_ENABLED_MODULES)。" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
|
||||
const modules = ref<any[]>([])
|
||||
|
||||
// 全部功能对演示账户开放(后端 HMC_ENABLED_MODULES 为准)
|
||||
const MODULE_DEFS = [
|
||||
{ key: 'order', name: '订单管理', desc: '订单全流程:报价下单、生产进度、图纸管理', price: 299 },
|
||||
{ key: 'quote', name: '智能报价', desc: '画图自动算料报价,型材/玻璃/五金明细', price: 199 },
|
||||
{ key: 'optimize', name: '优化下料', desc: '一维型材优化切割,提高利用率', price: 199 },
|
||||
{ key: 'template', name: '样板库', desc: '254 套官方窗型样板 + 自定义样板', price: 0 },
|
||||
{ key: 'warehouse', name: '仓库库存', desc: '材料出入库、库存预警', price: 99 },
|
||||
{ key: 'render', name: '效果图渲染', desc: '照片级渲染(规划中)', price: 399 },
|
||||
{ key: 'order', name: '订单管理', desc: '订单全流程:报价下单、生产进度、图纸管理、打印' },
|
||||
{ key: 'quote', name: '智能报价', desc: '画图自动算料报价,型材/玻璃/五金明细,接价格本' },
|
||||
{ key: 'optimize', name: '优化下料', desc: '一维型材优化切割(FFD),合同级下料方案' },
|
||||
{ key: 'template', name: '样板库', desc: '254 套官方窗型样板 + 自定义样板' },
|
||||
{ key: 'warehouse', name: '仓库库存', desc: '材料出入库、流水、库存预警' },
|
||||
{ key: 'render', name: '渲染工作台', desc: '效果图场景合成 + AI 炫图 + 3D 展示' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -42,12 +37,6 @@ onMounted(async () => {
|
||||
} catch { enabled = [] }
|
||||
modules.value = MODULE_DEFS.map(m => ({ ...m, enabled: enabled.includes(m.key) }))
|
||||
})
|
||||
|
||||
async function buy(m: any) {
|
||||
await ElMessageBox.confirm(
|
||||
`开通「${m.name}」需要 ¥${m.price}/年。演示环境未接入支付,可联系管理员在 HMC_ENABLED_MODULES 中开通。`,
|
||||
'购买', { type: 'info' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -55,6 +44,4 @@ async function buy(m: any) {
|
||||
.m-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.m-name { font-size: 16px; font-weight: 600; }
|
||||
.m-desc { color: #909399; font-size: 13px; min-height: 38px; margin: 10px 0; }
|
||||
.m-foot { display: flex; justify-content: space-between; align-items: center; }
|
||||
.m-price { color: #e6483d; font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -176,9 +176,11 @@ async function detail(row: any) {
|
||||
|
||||
async function toOrder(row: any) {
|
||||
await ElMessageBox.confirm(`将报价单 ${row.quote_no} 转为订单?`, '提示')
|
||||
const q = await api<any>('get', `/api/quotations/${row.id}`)
|
||||
await api('post', '/api/orders', { customer_id: row.customer_id, money: row.total,
|
||||
drawing_data: '', note: `来自报价单${row.quote_no}` })
|
||||
ElMessage.success('订单已创建')
|
||||
drawing_data: q.drawing_data || '',
|
||||
note: `来自报价单${row.quote_no}` })
|
||||
ElMessage.success('订单已创建(含图纸)')
|
||||
router.push('/orders')
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user