feat: v0.8 平台扩展——3D渲染/打印中心/下料方案/设备/分享/验证码登录

- 3D展示: Three.js挤出重建窗型(玻璃半透明+型材盒体), 自研轨道控制
- 效果图渲染: Canvas场景合成(墙面/玻璃渐变高光/投影/三场景), 设置持久化
- 打印中心: A4生产订单模板(图纸+进度+签字栏), 打印CSS隔离
- 下料方案: 合同级汇总订单算料需求→FFD优化, 修正利用率为已排入/已用料
- 设备模块: 序列号+激活码绑定, 防重复绑定
- 订单分享: HMAC可逆token + 免登录公开只读页(customOrder-share)
- 验证码登录: mock短信通道全流程可用(发码/校验/自动注册), 接签名后换provider
- 帮助文章接口; 新增支付单适配层(微信/支付宝凭据就绪即接通)
- 前端新增6页面, 共18页面
This commit is contained in:
HHY
2026-09-20 12:10:26 -07:00
parent b2730af80b
commit 3f871d7eeb
19 changed files with 1192 additions and 3 deletions
+64
View File
@@ -0,0 +1,64 @@
<template>
<el-card>
<div class="toolbar">
<el-select v-model="contractId" filterable placeholder="选择合同" style="width:300px">
<el-option v-for="c in contracts" :key="c.id" :label="`${c.contract_no} ${c.customer_name || ''}`" :value="c.id" />
</el-select>
<span>锯口</span>
<el-input-number v-model="kerf" :min="0" :max="50" size="small" controls-position="right" style="width:90px" />
<el-button type="primary" :loading="loading" :disabled="!contractId" @click="run">生成下料方案</el-button>
</div>
<template v-if="result">
<el-alert type="success" :closable="false" show-icon
:title="`合同 ${contractNo} · ${result.order_count} 个订单 · 利用率 ${(result.stats.utilization * 100).toFixed(1)}% · 用料 ${result.stats.stock_bars_used} 根`"
style="margin-bottom:12px" />
<el-table :data="result.plans" border size="small">
<el-table-column label="原料" width="180">
<template #default="{ row }">{{ row.stock_label }}({{ row.stock_length }}mm)</template>
</el-table-column>
<el-table-column label="切割组合">
<template #default="{ row }">
<el-tag v-for="(c, i) in row.pieces" :key="i" size="small" style="margin:2px">
{{ c.label || '件' }} {{ c.length }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="waste" label="余料mm" width="90" />
</el-table>
<el-alert v-if="result.unplaced.length" type="warning" :closable="false"
:title="`未排入 ${result.unplaced.length} 件(库存不足)`" style="margin-top:10px" />
</template>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
const contracts = ref<any[]>([])
const contractId = ref<number | undefined>()
const contractNo = ref('')
const kerf = ref(5)
const loading = ref(false)
const result = ref<any>(null)
async function run() {
loading.value = true
try {
const d = await api<any>('post', '/api/cut_solution', { contract_id: contractId.value, kerf: kerf.value })
result.value = d
contractNo.value = d.contract_no
ElMessage.success(`已生成:利用率 ${(d.stats.utilization * 100).toFixed(1)}%`)
} finally { loading.value = false }
}
onMounted(async () => {
const d = await api<any>('get', '/api/contracts?page=1&page_size=100')
contracts.value = d.contracts?.data ?? []
})
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
</style>
+58
View File
@@ -0,0 +1,58 @@
<template>
<el-row :gutter="16">
<el-col :xs="24" :md="14">
<el-card header="我的设备">
<el-table :data="devices" stripe size="small">
<el-table-column prop="sn" label="序列号" min-width="140" />
<el-table-column prop="name" label="名称" min-width="110" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '已激活' : '未激活' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="activated_at" label="激活时间" width="160" />
</el-table>
<el-empty v-if="!devices.length" description="暂无设备,输入序列号与激活码绑定" :image-size="60" />
</el-card>
</el-col>
<el-col :xs="24" :md="10">
<el-card header="设备激活绑定">
<el-form :model="form" label-width="80px">
<el-form-item label="序列号"><el-input v-model="form.sn" placeholder="设备SN" /></el-form-item>
<el-form-item label="激活码"><el-input v-model="form.activate_code" placeholder="随机激活码(首绑自动生成)" /></el-form-item>
<el-form-item label="名称"><el-input v-model="form.name" placeholder="设备备注名" /></el-form-item>
<el-button type="primary" @click="activate">激活绑定</el-button>
</el-form>
<p class="tip">设备对接协议(开料机/切割锯)通过激活码与账户绑定,对接细节见使用指南。</p>
</el-card>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
const devices = ref<any[]>([])
const form = reactive({ sn: '', activate_code: '', name: '' })
async function load() {
devices.value = await api<any>('get', '/api/devices')
}
async function activate() {
if (!form.sn) return ElMessage.warning('请输入序列号')
await api('post', '/api/device-activate', form)
ElMessage.success('激活成功')
form.sn = form.activate_code = form.name = ''
load()
}
onMounted(load)
</script>
<style scoped>
.tip { color: #909399; font-size: 12px; margin-top: 10px; }
</style>
+5
View File
@@ -16,6 +16,11 @@
<el-menu-item index="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item>
<el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item>
<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="/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>
<el-menu-item index="/market"><el-icon><ShoppingCart /></el-icon>商城</el-menu-item>
<el-menu-item index="/guide"><el-icon><QuestionFilled /></el-icon>使用指南</el-menu-item>
</el-menu>
+44
View File
@@ -13,6 +13,23 @@
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password />
</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-form>
</el-tab-pane>
<el-tab-pane label="注册" name="register">
@@ -48,6 +65,33 @@ 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('请输入手机号和密码')
+11 -1
View File
@@ -24,11 +24,12 @@
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="230" fixed="right">
<el-table-column label="操作" width="290" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openDetail(row)">图纸</el-button>
<el-button v-for="act in nextActions(row.status)" :key="act.to" size="small"
:type="act.type" @click="changeStatus(row, act.to)">{{ act.label }}</el-button>
<el-button size="small" type="success" plain @click="share(row)">分享</el-button>
<el-button size="small" type="danger" plain @click="remove(row)">删</el-button>
</template>
</el-table-column>
@@ -165,6 +166,15 @@ async function remove(row: any) {
load()
}
async function share(row: any) {
const d = await api<any>('post', `/api/share-order/${row.id}`)
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 })
}
// ---- 新建 ----
const createVisible = ref(false)
const saving = ref(false)
+85
View File
@@ -0,0 +1,85 @@
<template>
<el-card>
<div class="toolbar no-print">
<el-select v-model="orderType" style="width:140px" @change="load">
<el-option label="门窗订单" :value="0" />
<el-option label="阳光房订单" :value="1" />
</el-select>
<el-select v-model="orderId" filterable placeholder="选择订单" style="width:280px" @change="onPick">
<el-option v-for="o in orders" :key="o.id" :label="`${o.order_no} ${o.customer_name || ''}`" :value="o.id" />
</el-select>
<el-button type="primary" :disabled="!current" @click="print">打印</el-button>
</div>
<div v-if="current" class="a4" id="print-area">
<h2 class="doc-title">生产订单</h2>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="订单号">{{ current.order_no }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ current.customer_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ current.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="地址">{{ current.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ current.count }}</el-descriptions-item>
<el-descriptions-item label="金额">¥{{ current.money }}</el-descriptions-item>
</el-descriptions>
<div class="pic-box">
<img v-if="current.pic_url" :src="current.pic_url" alt="图纸" />
<div v-else class="no-pic">暂无图纸缩略图</div>
</div>
<el-divider>生产进度</el-divider>
<table class="simple">
<tr v-for="p in current.processes" :key="p.name">
<td style="width:90px">{{ p.name }}</td>
<td>{{ p.status === 2 ? `已完成 ${p.finish_time || ''}` : p.status === 1 ? '进行中' : '未开始' }}</td>
</tr>
</table>
<p class="sign">客户签字:____________  日期:____________</p>
</div>
<el-empty v-else description="选择订单后预览打印" />
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api'
const orders = ref<any[]>([])
const orderId = ref<number | undefined>()
const orderType = ref(0)
const current = ref<any>(null)
async function load() {
const d = orderType.value === 1
? await api<any>('get', `/api/sunny_house_orders?page=1&page_size=100`)
: await api<any>('post', '/api/orders/search', { page: 1, page_size: 100 })
orders.value = orderType.value === 1 ? d.data : d.data
current.value = null
}
async function onPick(oid: number | undefined) {
if (!oid) { current.value = null; return }
current.value = await api<any>('get', `/api/orders/${oid}`)
}
function print() { window.print() }
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.a4 { width: 210mm; min-height: 240mm; margin: 0 auto; background: #fff; padding: 14mm; box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.doc-title { text-align: center; letter-spacing: 8px; }
.pic-box { height: 80mm; display: flex; align-items: center; justify-content: center; background: #f7f8f9; margin-top: 12px; }
.pic-box img { max-width: 100%; max-height: 100%; }
.no-pic { color: #909399; }
.simple { width: 100%; border-collapse: collapse; }
.simple td { border: 1px solid #dcdfe6; padding: 5px 8px; font-size: 13px; }
.sign { margin-top: 30px; text-align: right; color: #606266; }
@media print {
.no-print { display: none !important; }
.a4 { box-shadow: none; width: 100%; }
body * { visibility: hidden; }
#print-area, #print-area * { visibility: visible; }
#print-area { position: absolute; left: 0; top: 0; }
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<el-card>
<template #header>
<div class="head">
<el-radio-group v-model="mode" size="small">
<el-radio-button value="render">效果图渲染</el-radio-button>
<el-radio-button value="threed">3D 展示</el-radio-button>
</el-radio-group>
<div class="sel">
<el-select v-model="templateId" filterable placeholder="选择样板" style="width:200px" @change="loadTemplate">
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
</el-select>
</div>
</div>
</template>
<!-- 渲染设置 -->
<div class="settings" v-if="mode === 'render'">
<span>墙面色</span><el-color-picker v-model="setting.wall_color" size="small" @change="saveSetting" />
<span>玻璃色</span><el-color-picker v-model="setting.glass_tint" size="small" @change="saveSetting" />
<span>场景</span>
<el-radio-group v-model="setting.scene" size="small" @change="saveSetting">
<el-radio-button value="indoor">室内</el-radio-button>
<el-radio-button value="outdoor">室外</el-radio-button>
<el-radio-button value="studio">影棚</el-radio-button>
</el-radio-group>
<el-button size="small" type="primary" @click="print">导出图片</el-button>
</div>
<div class="view-box">
<!-- 2D 效果图渲染(自研 Canvas 场景合成) -->
<div v-show="mode === 'render'" class="render-stage" :style="{ background: sceneBg }">
<div class="wall" :style="{ background: `linear-gradient(180deg, ${setting.wall_color}, #e8e2d8)` }">
<div v-if="drawing" class="window-frame">
<RenderCanvas :drawing-data="drawing" :tint="setting.glass_tint" />
</div>
<div v-else class="empty">请选择样板</div>
</div>
</div>
<!-- 3D -->
<WindoorThreed v-show="mode === 'threed'" :drawing-data="drawing ?? ''" class="threed-box" />
</div>
<p class="note">注:当前为本地实时渲染;照片级 AI 渲染需接入第三方渲染服务(规划中)。</p>
</el-card>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { api } from '../api'
import WindoorThreed from '../components/WindoorThreed.vue'
import RenderCanvas from '../components/RenderCanvas.vue'
const mode = ref<'render' | 'threed'>('render')
const templates = ref<any[]>([])
const templateId = ref<number | undefined>()
const drawing = ref<string>('')
const setting = reactive({ wall_color: '#f5f0e8', scene: 'indoor', glass_tint: '#cfe3ee' })
const sceneBg = computed(() => ({
indoor: 'linear-gradient(180deg,#efe9df,#dcd3c5)',
outdoor: 'linear-gradient(180deg,#bcd6ea,#8fb6d6)',
studio: 'linear-gradient(180deg,#f2f2f2,#d9d9d9)',
} as Record<string, string>)[setting.scene] ?? '#eee')
async function loadTemplate(tid: number | undefined) {
if (!tid) { drawing.value = ''; return }
const t = await api<any>('get', `/api/windoor/templates/${tid}`)
drawing.value = t.drawing_data
}
async function saveSetting() {
await api('post', '/api/rendering-info', setting)
}
onMounted(async () => {
templates.value = await api<any>('get', '/api/windoor/templates?page=1&page_size=254')
Object.assign(setting, await api<any>('get', '/api/rendering-info'))
})
function print() { window.print() }
</script>
<style scoped>
.head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; }
.sel { display: flex; gap: 8px; }
.settings { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; font-size: 13px; color: #606266; flex-wrap: wrap; }
.view-box { height: 60vh; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
.render-stage { height: 100%; padding: 24px; display: flex; align-items: center; justify-content: center; }
.wall { width: min(720px, 100%); height: 100%; border-radius: 8px; box-shadow: 0 6px 24px rgba(0,0,0,.18);
display: flex; align-items: center; justify-content: center; }
.window-frame { width: 78%; height: 82%; background: #fff; border-radius: 4px; padding: 10px;
box-shadow: inset 0 0 12px rgba(0,0,0,.25); }
.threed-box { height: 100%; }
.empty { color: #909399; }
.note { color: #b0b5bc; font-size: 12px; margin-top: 8px; }
@media print { .settings, .head { display: none; } }
</style>
+50
View File
@@ -0,0 +1,50 @@
<template>
<div class="share-page">
<el-card class="share-card" v-loading="loading">
<template v-if="order">
<h2>订单查询</h2>
<p class="no">{{ order.order_no }}</p>
<el-descriptions :column="1" border>
<el-descriptions-item label="状态">
<el-tag size="small">{{ ['意向单', '正式单', '生产中', '已完工', '已取消'][order.status] }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="数量">{{ order.count }}</el-descriptions-item>
<el-descriptions-item label="面积">{{ order.area }} ㎡</el-descriptions-item>
<el-descriptions-item label="下单时间">{{ order.created_at }}</el-descriptions-item>
</el-descriptions>
<img v-if="order.pic_url" :src="order.pic_url" class="pic" alt="订单图纸" />
<p class="tip">此页面为只读订单分享页,如需修改请联系商家。</p>
</template>
<el-empty v-else-if="!loading" description="订单不存在或未开放分享" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const route = useRoute()
const order = ref<any>(null)
const loading = ref(true)
onMounted(async () => {
try {
// 公开接口:不带登录态
const res = await axios.get(`/api/shared-order/${route.params.token}`)
if (res.data.code === 1) order.value = res.data.data
else ElMessage.error(res.data.message)
} catch { /* ignore */ } finally { loading.value = false }
})
</script>
<style scoped>
.share-page { min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #35506b, #6b8cae); }
.share-card { width: 420px; text-align: center; }
.no { font-size: 20px; font-weight: 700; color: #303133; letter-spacing: 2px; }
.pic { max-width: 100%; max-height: 300px; margin-top: 12px; }
.tip { color: #909399; font-size: 12px; margin-top: 10px; }
</style>