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
+37 -1
View File
@@ -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>
+210
View File
@@ -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)
}
+1
View File
@@ -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: '下料方案' } },
+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')
}