feat: P0-3 打印模板体系(区块化模板CRUD+打印中心重构)
This commit is contained in:
@@ -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%; }
|
||||
|
||||
Reference in New Issue
Block a user