feat: v1.0 AI炫图工作台上线+业务做实+按需裁剪+主题精致化

- AI炫图: 自研程序化场景合成引擎(风格×时间×场景, 家具剪影/光束/暗角,
  seed构图微差), 阶段化生成动画+四方案画廊+PNG下载, 引擎抽为共享模块
- 业务做实: 报价接入产品价格本(use_price_book), 注册邀请码+返利真实计数,
  报价单转订单携带图纸
- 按需裁剪: 下线短信验证码登录/支付收银, 商城改为功能总览
- 主题精致化: 品牌色设计变量+Element Plus主题覆盖+视觉升级
- 全接口回归通过, 移除项405确认
This commit is contained in:
HHY
2026-09-20 13:15:31 -07:00
parent 453decf2e4
commit a20f3df854
15 changed files with 566 additions and 214 deletions
+147
View File
@@ -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>
+1
View File
@@ -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>
+28 -56
View File
@@ -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>
+9 -22
View File
@@ -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>
+4 -2
View File
@@ -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')
}