feat: v1.0 AI炫图工作台上线+业务做实+按需裁剪+主题精致化
- AI炫图: 自研程序化场景合成引擎(风格×时间×场景, 家具剪影/光束/暗角, seed构图微差), 阶段化生成动画+四方案画廊+PNG下载, 引擎抽为共享模块 - 业务做实: 报价接入产品价格本(use_price_book), 注册邀请码+返利真实计数, 报价单转订单携带图纸 - 按需裁剪: 下线短信验证码登录/支付收银, 商城改为功能总览 - 主题精致化: 品牌色设计变量+Element Plus主题覆盖+视觉升级 - 全接口回归通过, 移除项405确认
This commit is contained in:
@@ -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