feat: P0-3 打印模板体系(区块化模板CRUD+打印中心重构)

This commit is contained in:
HHY
2026-09-25 03:13:38 -07:00
parent 3d0f43d567
commit 87f9869829
8 changed files with 360 additions and 28 deletions
+202 -28
View File
@@ -8,65 +8,234 @@
<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-select v-model="formatId" style="width:200px" placeholder="打印模板" @change="onFormatChange">
<el-option v-for="f in formats" :key="f.id" :label="`${f.name}${f.is_default ? ' (默认)' : ''}`" :value="f.id" />
</el-select>
<el-button :disabled="!current" @click="fmtVisible = true">模板管理</el-button>
<el-button type="primary" :disabled="!current" @click="print">打印</el-button>
</div>
<!-- A4 预览:按模板区块渲染 -->
<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>
<template v-for="(b, i) in activeBlocks" :key="i">
<h2 v-if="b.type === 'title'" class="doc-title">{{ b.text }}</h2>
<el-descriptions v-else-if="b.type === 'info'" :column="3" border size="small">
<el-descriptions-item v-for="f in b.fields" :key="f" :label="FIELD_DEFS[String(f)]?.label ?? f">
{{ renderField(String(f)) }}
</el-descriptions-item>
</el-descriptions>
<div v-else-if="b.type === 'image'" class="pic-box">
<img v-if="current.pic_url" :src="current.pic_url" alt="图纸" />
<div v-else class="no-pic">暂无图纸缩略图</div>
</div>
<template v-else-if="b.type === 'table' && b.kind === 'process'">
<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>
</template>
<template v-else-if="b.type === 'table' && b.kind === 'bom'">
<el-divider>型材下料清单</el-divider>
<table class="simple">
<tr class="thead"><td>用途</td><td>单件长度(mm)</td><td>数量(件)</td><td>总长(m)</td></tr>
<tr v-for="(row, ri) in bom?.bars ?? []" :key="ri">
<td>{{ row.note || row.kind }}</td><td>{{ row.length }}</td><td>{{ row.qty }}</td>
<td>{{ (row.length * row.qty / 1000).toFixed(2) }}</td>
</tr>
<tr v-if="!bom?.bars?.length"><td colspan="4" class="no-pic">无下料数据</td></tr>
</table>
</template>
<p v-else-if="b.type === 'sign'" class="sign">客户签字:____________  日期:____________</p>
</template>
</div>
<el-empty v-else description="选择订单后预览打印" />
<!-- 模板管理 -->
<el-dialog v-model="fmtVisible" title="打印模板管理" width="680px">
<div class="fmt-list">
<el-table :data="formats" size="small" border>
<el-table-column prop="name" label="模板" min-width="150" />
<el-table-column label="默认" width="70">
<template #default="{ row }">
<el-tag v-if="row.is_default" size="small" type="success">默认</el-tag>
<el-button v-else size="small" text @click="setDefault(row)">设默认</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="110">
<template #default="{ row }">
<el-button size="small" @click="editFormat(row)">编辑</el-button>
<el-button size="small" type="danger" plain @click="removeFormat(row)">删</el-button>
</template>
</el-table-column>
</el-table>
<el-button size="small" type="primary" plain style="margin-top:8px" @click="newFormat">新增模板</el-button>
</div>
<el-divider content-position="left">{{ fmtForm.id ? `编辑:${fmtForm.name}` : '新增模板' }}</el-divider>
<el-form label-width="70px" size="small">
<el-form-item label="模板名"><el-input v-model="fmtForm.name" style="width:240px" /></el-form-item>
<el-form-item label="区块设计">
<div class="blocks-editor">
<div v-for="(b, i) in fmtForm.blocks" :key="i" class="block-row">
<el-tag size="small" effect="dark" class="block-tag">{{ BLOCK_LABEL[b.type] }}</el-tag>
<el-input v-if="b.type === 'title'" v-model="b.text" size="small" style="width:180px" placeholder="标题文字" />
<el-select v-if="b.type === 'info'" v-model="b.fields" multiple size="small" style="min-width:280px"
placeholder="选择展示字段">
<el-option v-for="(def, k) in FIELD_DEFS" :key="k" :label="def.label" :value="k" />
</el-select>
<el-select v-if="b.type === 'table'" v-model="b.kind" size="small" style="width:130px">
<el-option value="process" label="生产进度" /><el-option value="bom" label="BOM 下料清单" />
</el-select>
<span v-if="b.type === 'image'" class="block-note">订单图纸缩略图</span>
<span v-else-if="b.type === 'sign'" class="block-note">签字/日期行</span>
<div style="flex:1" />
<el-button size="small" text :disabled="i === 0" @click="move(i, -1)">↑</el-button>
<el-button size="small" text :disabled="i === fmtForm.blocks.length - 1" @click="move(i, 1)">↓</el-button>
<el-button size="small" text type="danger" @click="fmtForm.blocks.splice(i, 1)">删</el-button>
</div>
<el-dropdown @command="(t: string) => addBlock(t)">
<el-button size="small" type="primary" plain>+ 添加区块</el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-for="(lbl, t) in BLOCK_LABEL" :key="t" :command="t">{{ lbl }}</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="fmtVisible = false">关闭</el-button>
<el-button type="primary" @click="saveFormat">保存模板</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
const FIELD_DEFS: Record<string, { label: string; render: (o: any) => string }> = {
order_no: { label: '订单号', render: o => o.order_no },
customer_name: { label: '客户', render: o => o.customer_name || '-' },
phone: { label: '电话', render: o => o.phone || '-' },
address: { label: '地址', render: o => o.address || '-' },
count: { label: '数量', render: o => String(o.count ?? 0) },
area: { label: '面积', render: o => `${o.area ?? 0} ㎡` },
money: { label: '金额', render: o => `¥${o.money ?? 0}` },
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 orders = ref<any[]>([])
const orderId = ref<number | undefined>()
const orderType = ref(0)
const current = ref<any>(null)
const bom = ref<any>(null)
const formats = ref<any[]>([])
const formatId = ref<number | undefined>()
const fmtVisible = ref(false)
const fmtForm = reactive<any>({ id: 0, name: '', blocks: [], is_default: 0 })
const activeBlocks = computed<any[]>(() => {
const f = formats.value.find(x => x.id === formatId.value)
return f?.blocks ?? []
})
function renderField(f: string) {
const def = FIELD_DEFS[f]
return def ? def.render(current.value) : ''
}
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
const d = await api<any>('post', '/api/orders/search', { page: 1, page_size: 100, status: orderType.value })
orders.value = d.data
current.value = null
}
async function loadFormats(keep = false) {
formats.value = (await api<any[]>('get', '/api/order-formats')) ?? []
if (!keep) {
const def = formats.value.find(f => f.is_default) ?? formats.value[0]
formatId.value = def?.id
}
}
async function onPick(oid: number | undefined) {
if (!oid) { current.value = null; return }
current.value = await api<any>('get', `/api/orders/${oid}`)
await refreshBom()
}
async function refreshBom() {
bom.value = null
if (!current.value || !activeBlocks.value.some(b => b.type === 'table' && b.kind === 'bom')) return
try { bom.value = await api<any>('get', `/api/orders/${current.value.id}/bom`) } catch { /* 无图纸时静默 */ }
}
function onFormatChange() { refreshBom() }
function print() { window.print() }
onMounted(load)
// ---- 模板管理 ----
function newFormat() {
Object.assign(fmtForm, { id: 0, name: '', blocks: [{ type: 'title', text: '生产订单' }], is_default: 0 })
}
function editFormat(row: any) {
Object.assign(fmtForm, { id: row.id, name: row.name, blocks: JSON.parse(JSON.stringify(row.blocks)), is_default: row.is_default })
}
function addBlock(t: string) {
const seed: Record<string, any> = {
title: { type: 'title', text: '标题' },
info: { type: 'info', fields: ['order_no', 'customer_name', 'count', 'money'] },
image: { type: 'image' },
table: { type: 'table', kind: 'process' },
sign: { type: 'sign' },
}
fmtForm.blocks.push(JSON.parse(JSON.stringify(seed[t])))
}
function move(idx: number | string, dir: number) {
const i = Number(idx)
const arr = fmtForm.blocks
const [x] = arr.splice(i, 1)
arr.splice(i + dir, 0, x)
}
async function saveFormat() {
if (!fmtForm.name.trim()) return ElMessage.warning('请填写模板名')
if (fmtForm.id) await api('put', `/api/order-formats/${fmtForm.id}`, fmtForm)
else await api('post', '/api/order-formats', fmtForm)
ElMessage.success('模板已保存')
await loadFormats(true)
editFormat(formats.value.find(f => f.id === fmtForm.id)!)
}
async function setDefault(row: any) {
await api('put', `/api/order-formats/${row.id}`, { ...row, is_default: 1 })
ElMessage.success('已设为默认')
loadFormats(true)
}
async function removeFormat(row: any) {
await ElMessageBox.confirm(`确定删除模板「${row.name}」?`, '提示', { type: 'warning' })
await api('delete', `/api/order-formats/${row.id}`)
ElMessage.success('已删除')
await loadFormats(true)
if (formatId.value === row.id) formatId.value = formats.value[0]?.id
}
onMounted(() => { load(); loadFormats() })
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.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; }
@@ -74,7 +243,12 @@ onMounted(load)
.no-pic { color: #909399; }
.simple { width: 100%; border-collapse: collapse; }
.simple td { border: 1px solid #dcdfe6; padding: 5px 8px; font-size: 13px; }
.simple .thead td { background: #f5f7fa; font-weight: 600; }
.sign { margin-top: 30px; text-align: right; color: #606266; }
.blocks-editor { width: 100%; }
.block-row { display: flex; gap: 8px; align-items: center; padding: 4px 0; }
.block-tag { width: 64px; justify-content: center; }
.block-note { color: #909399; font-size: 12px; }
@media print {
.no-print { display: none !important; }
.a4 { box-shadow: none; width: 100%; }