feat: P1批次3 生产域(车间管理/扫码跟踪/双通道优化/订单利润/条码打印)

This commit is contained in:
HHY
2026-09-25 06:39:07 -07:00
parent d05a91b7bc
commit fbe21c17b5
16 changed files with 683 additions and 7 deletions
+1
View File
@@ -30,6 +30,7 @@ const router = createRouter({
{ 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: 'workshop', component: () => import('../views/WorkshopView.vue'), meta: { title: '车间管理' } },
{ path: 'cut_solution', component: () => import('../views/CutSolutionView.vue'), meta: { title: '下料方案' } },
{ path: 'devices', component: () => import('../views/DevicesView.vue'), meta: { title: '设备管理' } },
{ path: 'market', component: () => import('../views/MarketView.vue'), meta: { title: '商城' } },
+1
View File
@@ -30,6 +30,7 @@
<el-menu-item index="/measure"><el-icon><ScaleToOriginal /></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="/workshop"><el-icon><SetUp /></el-icon>车间管理</el-menu-item>
<el-menu-item index="/printer"><el-icon><Printer /></el-icon>打印中心</el-menu-item>
<el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item>
<el-menu-item index="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item>
+14 -2
View File
@@ -1,5 +1,14 @@
<template>
<el-row :gutter="16">
<el-card>
<div class="row" style="margin-bottom:10px">
<span>优化通道:</span>
<el-radio-group v-model="method" size="small">
<el-radio-button value="fast">快速优化(FFD)</el-radio-button>
<el-radio-button value="fine">详细优化(BFD+余料均衡)</el-radio-button>
</el-radio-group>
<span class="tip">详细优化逐件选择最贴合原料并做余料均衡,综合利用率更高(对齐源站双通道)</span>
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-card header="型材需求(每件长度 × 数量)">
<div v-for="(d, i) in demands" :key="i" class="row">
@@ -59,6 +68,7 @@
</el-table>
</template>
</el-card>
</el-card>
</template>
<script setup lang="ts">
@@ -69,13 +79,14 @@ import { api } from '../api'
const demands = reactive([{ label: '边框', length: 1200, qty: 3 }, { label: '中梃', length: 800, qty: 2 }])
const stocks = reactive([{ label: '6米型材', length: 6000, qty: 2 }])
const kerf = ref(5)
const method = ref<'fast' | 'fine'>('fast')
const loading = ref(false)
const result = ref<any>(null)
async function run() {
loading.value = true
try {
result.value = await api<any>('post', '/api/optimize1d', { demands, stocks, kerf: kerf.value })
result.value = await api<any>('post', '/api/optimize1d', { demands, stocks, kerf: kerf.value, method: method.value })
ElMessage.success(`优化完成,利用率 ${(result.value.stats.utilization * 100).toFixed(1)}%`)
} finally { loading.value = false }
}
@@ -83,6 +94,7 @@ async function run() {
<style scoped>
.row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
.tip { color: #909399; font-size: 12px; }
.result-head { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
.util { font-size: 20px; font-weight: 700; }
.util-label { color: #909399; }
+25 -2
View File
@@ -49,6 +49,9 @@
</table>
</template>
<p v-else-if="b.type === 'sign'" class="sign">客户签字:____________  日期:____________</p>
<div v-else-if="b.type === 'barcode'" class="barcode-box">
<svg v-for="(o, i) in [current]" :key="i" :ref="el => setBarcodeRef(el as SVGElement)" class="barcode-svg" />
</div>
</template>
</div>
<el-empty v-else description="选择订单后预览打印" />
@@ -114,7 +117,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
@@ -129,7 +132,7 @@ const FIELD_DEFS: Record<string, { label: string; render: (o: any) => string }>
created_at: { label: '下单时间', render: o => String(o.created_at ?? '').slice(0, 10) },
note: { label: '备注', render: o => o.note || '-' },
}
const BLOCK_LABEL: Record<string, string> = { title: '标题', info: '订单信息', image: '图纸', table: '表格', sign: '签字' }
const BLOCK_LABEL: Record<string, string> = { title: '标题', info: '订单信息', image: '图纸', table: '表格', sign: '签字', barcode: '条码' }
const orders = ref<any[]>([])
const orderId = ref<number | undefined>()
@@ -179,6 +182,10 @@ async function refreshBom() {
function onFormatChange() { refreshBom() }
// 模板含条码块时渲染条码
watch([() => current.value?.id, () => formatId.value], () => renderBarcodes())
watch(() => fmtVisible.value, v => { if (!v) renderBarcodes() })
function print() { window.print() }
// ---- 模板管理 ----
@@ -197,10 +204,26 @@ function addBlock(t: string) {
image: { type: 'image' },
table: { type: 'table', kind: 'process' },
sign: { type: 'sign' },
barcode: { type: 'barcode' },
}
fmtForm.blocks.push(JSON.parse(JSON.stringify(seed[t])))
}
// ---- 条码渲染(Code128, 订单号) ----
const barcodeRefs: SVGElement[] = []
function setBarcodeRef(el: SVGElement | null) {
if (el && !barcodeRefs.includes(el)) barcodeRefs.push(el)
}
async function renderBarcodes() {
if (!current.value) return
await nextTick()
const JsBarcode = (await import('jsbarcode')).default
for (const svg of barcodeRefs) {
try { JsBarcode(svg, current.value.order_no || '0000', { format: 'CODE128', height: 44, fontSize: 13, margin: 4 }) } catch { /* 忽略 */ }
}
}
function move(idx: number | string, dir: number) {
const i = Number(idx)
const arr = fmtForm.blocks
+33
View File
@@ -39,6 +39,31 @@
<el-tag v-for="s in statusDist" :key="s.status" style="margin:2px">
{{ statusLabel(s.status) }}:{{ s.count }}</el-tag>
</el-space>
<!-- 订单利润(order-profit 语义:金额 vs 材料成本估算) -->
<div class="no-print">
<h4 class="sec">订单利润
<el-button size="small" text type="primary" @click="loadProfit">刷新近 90 天</el-button>
</h4>
<el-row :gutter="12" v-if="profit.summary">
<el-col :span="6"><div class="stat"><span class="num">¥{{ profit.summary.total_money }}</span><span class="label">订单总额</span></div></el-col>
<el-col :span="6"><div class="stat"><span class="num">¥{{ profit.summary.total_cost }}</span><span class="label">材料成本估算</span></div></el-col>
<el-col :span="6"><div class="stat"><span class="num" :style="{ color: profit.summary.total_profit >= 0 ? '#67c23a' : '#e6483d' }">¥{{ profit.summary.total_profit }}</span><span class="label">毛利</span></div></el-col>
<el-col :span="6"><div class="stat"><span class="num">{{ profit.summary.avg_margin }}%</span><span class="label">平均毛利率</span></div></el-col>
</el-row>
<el-table :data="profit.rows || []" size="small" border max-height="320" style="margin-top:10px">
<el-table-column prop="order_no" label="订单号" width="160" />
<el-table-column prop="customer_name" label="客户" min-width="100" />
<el-table-column prop="money" label="金额" width="100" />
<el-table-column prop="cost" label="成本估算" width="100" />
<el-table-column label="毛利" width="100">
<template #default="{ row }">
<span :style="{ color: row.profit >= 0 ? '#67c23a' : '#e6483d' }">¥{{ row.profit }}</span>
</template>
</el-table-column>
<el-table-column prop="margin" label="毛利率%" width="90" />
</el-table>
</div>
</el-card>
</template>
@@ -78,6 +103,14 @@ async function load() {
function exportExcel() { apiDownload('/api/reports/excel-statistics', 'statistics.xlsx') }
// ---- 订单利润 ----
const profit = ref<any>({})
async function loadProfit() {
profit.value = await api<any>('get', '/api/reports/order-profit?days=90')
}
onMounted(loadProfit)
onMounted(load)
</script>
+248
View File
@@ -0,0 +1,248 @@
<template>
<div>
<el-tabs v-model="tab">
<!-- ============ 跟单中心看板 ============ -->
<el-tab-pane label="跟单中心" name="board">
<div class="board" v-loading="boardLoading">
<div v-for="(list, name) in board.columns || {}" :key="name" class="board-col">
<div class="col-head">{{ name }}<el-badge :value="list.length" type="info" class="col-bdg" /></div>
<div v-for="o in list" :key="o.id" class="col-card">
<b>{{ o.order_no }}</b>
<span>{{ o.customer_name || '-' }}</span>
<span class="money">¥{{ o.money }}</span>
</div>
<div v-if="!list.length" class="col-empty">-</div>
</div>
</div>
</el-tab-pane>
<!-- ============ 环节扫码 ============ -->
<el-tab-pane label="环节扫码" name="scan">
<div class="scan-bar">
<el-input v-model="scanNo" placeholder="输入或扫描订单号条码" style="width:280px"
@keyup.enter="doScan" clearable>
<template #append><el-button @click="startScan">摄像头</el-button></template>
</el-input>
<el-button type="primary" @click="doScan">查询进度</el-button>
<video ref="videoRef" class="scan-video" v-show="scanning" />
</div>
<el-alert v-if="scanError" :title="scanError" type="error" :closable="false" show-icon style="margin-bottom:10px" />
<el-card v-if="scanResult" shadow="never">
<template #header>
<div class="scan-head">
<span>{{ scanResult.order_no }} · {{ scanResult.customer_name || '-' }}({{ scanResult.status_label }})</span>
<span class="money">¥{{ scanResult.money }}</span>
</div>
</template>
<el-steps :active="scanActive" align-center>
<el-step v-for="p in scanResult.processes" :key="p.name" :title="p.name"
:status="p.status === 2 ? 'success' : p.status === 1 ? 'process' : 'wait'" />
</el-steps>
<div class="scan-ops">
<el-select v-model="scanProcess" style="width:120px" size="small">
<el-option v-for="p in scanResult.processes" :key="p.name" :label="p.name" :value="p.name" />
</el-select>
<el-select v-model="scanStatus" style="width:100px" size="small">
<el-option :value="0" label="未开始" /><el-option :value="1" label="进行中" /><el-option :value="2" label="完成" />
</el-select>
<el-button type="primary" size="small" @click="applyScanStatus">更新环节</el-button>
<span class="tip">扫码枪可直接对订单条码扫入输入框</span>
</div>
</el-card>
</el-tab-pane>
<!-- ============ 员工与派工 ============ -->
<el-tab-pane label="员工与派工" name="staff">
<el-row :gutter="14">
<el-col :span="10">
<el-card shadow="never" header="车间员工">
<el-table :data="staff" size="small" border>
<el-table-column prop="name" label="姓名" min-width="80" />
<el-table-column prop="role" label="环节" width="80" />
<el-table-column prop="phone" label="电话" width="110" />
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button size="small" text type="danger" @click="removeStaff(row)">删</el-button>
</template>
</el-table-column>
</el-table>
<div class="staff-add">
<el-input v-model="staffForm.name" size="small" placeholder="姓名" style="width:90px" />
<el-select v-model="staffForm.role" size="small" style="width:90px">
<el-option v-for="r in PROCESSES" :key="r" :label="r" :value="r" />
</el-select>
<el-input v-model="staffForm.phone" size="small" placeholder="电话" style="width:110px" />
<el-button size="small" type="primary" plain @click="addStaff">添加</el-button>
</div>
</el-card>
</el-col>
<el-col :span="14">
<el-card shadow="never" header="派工(进度节点指派员工)">
<div class="assign-form">
<el-select v-model="assignForm.order_id" filterable placeholder="选择订单" style="width:230px" size="small">
<el-option v-for="o in orderOptions" :key="o.id" :label="`${o.order_no} ${o.customer_name || ''}`" :value="o.id" />
</el-select>
<el-select v-model="assignForm.process_name" placeholder="环节" style="width:100px" size="small">
<el-option v-for="r in PROCESSES" :key="r" :label="r" :value="r" />
</el-select>
<el-select v-model="assignForm.staff_id" placeholder="员工" style="width:120px" size="small">
<el-option v-for="s in staff" :key="s.id" :label="`${s.name}(${s.role})`" :value="s.id" />
</el-select>
<el-button type="primary" size="small" @click="assign">派工</el-button>
</div>
<el-table :data="assignments" size="small" border max-height="360">
<el-table-column prop="created_at" label="时间" width="150" />
<el-table-column prop="order_no" label="订单" width="150" />
<el-table-column prop="process_name" label="环节" width="80" />
<el-table-column prop="staff_name" label="员工" width="90" />
<el-table-column prop="note" label="备注" min-width="90" />
</el-table>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
const PROCESSES = ['下料', '组装', '玻璃', '打胶', '五金', '质检', '完工']
const tab = ref('board')
const board = ref<any>({})
const boardLoading = ref(false)
const staff = ref<any[]>([])
const staffForm = reactive<any>({ name: '', role: '下料', phone: '' })
const orderOptions = ref<any[]>([])
const assignForm = reactive<any>({ order_id: undefined, process_name: '下料', staff_id: undefined })
const assignments = ref<any[]>([])
// ---- 看板 ----
async function loadBoard() {
boardLoading.value = true
try { board.value = await api<any>('get', '/api/workshop/board') } finally { boardLoading.value = false }
}
// ---- 扫码 ----
const scanNo = ref('')
const scanResult = ref<any>(null)
const scanError = ref('')
const scanProcess = ref('下料')
const scanStatus = ref(2)
const videoRef = ref<HTMLVideoElement>()
const scanning = ref(false)
let scanStream: MediaStream | null = null
let detector: any = null
const scanActive = computed(() =>
(scanResult.value?.processes ?? []).filter((p: any) => p.status === 2).length)
async function doScan() {
scanError.value = ''
scanResult.value = null
if (!scanNo.value.trim()) return
try {
scanResult.value = await api<any>('get', `/api/workshop/scan/${encodeURIComponent(scanNo.value.trim())}`)
scanProcess.value = scanResult.value.processes.find((p: any) => p.status !== 2)?.name ?? '完工'
} catch { scanError.value = `未找到订单 ${scanNo.value}` }
}
async function startScan() {
if (scanning.value) { stopScan(); return }
try {
scanStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
const video = videoRef.value!
video.srcObject = scanStream
await video.play()
scanning.value = true
const Detector = (window as any).BarcodeDetector
if (!Detector) return ElMessage.warning('当前浏览器不支持条码识别,请用扫码枪或手动输入')
detector = new Detector({ formats: ['code_128', 'code_39', 'qr_code'] })
const tick = async () => {
if (!scanning.value || !detector || !videoRef.value) return
try {
const codes = await detector.detect(videoRef.value)
if (codes.length) {
scanNo.value = codes[0].rawValue
stopScan()
doScan()
return
}
} catch { /* 帧失败忽略 */ }
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
} catch { ElMessage.error('无法访问摄像头') }
}
function stopScan() {
scanning.value = false
scanStream?.getTracks().forEach(t => t.stop())
scanStream = null
}
async function applyScanStatus() {
if (!scanResult.value) return
await api('post', `/api/orders/${scanResult.value.id}/process`, {
name: scanProcess.value, status: scanStatus.value,
})
ElMessage.success(`${scanProcess.value} 环节已更新`)
doScan()
}
// ---- 员工与派工 ----
async function loadStaff() {
staff.value = (await api<any[]>('get', '/api/workshop/staff')) ?? []
}
async function addStaff() {
if (!staffForm.name.trim()) return ElMessage.warning('请填写姓名')
await api('post', '/api/workshop/staff', staffForm)
ElMessage.success('已添加')
staffForm.name = ''; staffForm.phone = ''
loadStaff()
}
async function removeStaff(row: any) {
await ElMessageBox.confirm(`确定删除员工「${row.name}」?`, '提示', { type: 'warning' })
await api('delete', `/api/workshop/staff/${row.id}`)
loadStaff()
}
async function assign() {
if (!assignForm.order_id || !assignForm.staff_id) return ElMessage.warning('请选择订单与员工')
await api('post', '/api/workshop/assign', assignForm)
ElMessage.success('已派工')
loadAssignments()
}
async function loadAssignments() {
assignments.value = ((await api<any>('get', '/api/workshop/assignments?page=1&page_size=30')).data) ?? []
}
onMounted(async () => {
loadBoard(); loadStaff(); loadAssignments()
orderOptions.value = ((await api<any>('post', '/api/orders/search', { page: 1, page_size: 100 })).data) ?? []
})
onBeforeUnmount(stopScan)
</script>
<style scoped>
.board { display: flex; gap: 10px; overflow-x: auto; min-height: 300px; }
.board-col { flex: 1; min-width: 130px; background: #f7f8fa; border-radius: 8px; padding: 8px; }
.col-head { font-weight: 700; color: #303133; font-size: 13px; padding: 2px 4px 8px; display: flex; justify-content: space-between; }
.col-card { background: #fff; border: 1px solid #e4e7ed; border-radius: 6px; padding: 6px 8px; margin-bottom: 8px;
display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: #606266; }
.col-empty { text-align: center; color: #c0c4cc; }
.money { color: #e6483d; }
.scan-bar { display: flex; gap: 10px; margin-bottom: 12px; align-items: flex-start; }
.scan-video { width: 260px; height: 160px; border-radius: 8px; background: #000; object-fit: cover; }
.scan-head { display: flex; justify-content: space-between; font-weight: 600; }
.scan-ops { margin-top: 14px; display: flex; gap: 10px; align-items: center; }
.tip { color: #909399; font-size: 12px; }
.staff-add { display: flex; gap: 6px; margin-top: 10px; }
.assign-form { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
</style>