PrintDialog.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. <template>
  2. <el-dialog
  3. :model-value="modelValue"
  4. title="套打流程单"
  5. width="1100px"
  6. top="6vh"
  7. class="print-overprint-dialog"
  8. destroy-on-close
  9. append-to-body
  10. @close="onClose"
  11. >
  12. <div class="print-layout">
  13. <div class="print-left">
  14. <el-form :model="form" label-width="92px" size="default">
  15. <el-form-item v-if="!lockTemplate" label="套打模板">
  16. <el-select v-model="form.template_id" filterable placeholder="选择本厂识版模板" style="width:100%">
  17. <el-option v-for="item in templates" :key="item.id" :label="item.name + labelOf(item)" :value="item.id" />
  18. </el-select>
  19. <p v-if="!templates.length" class="empty-hint">还没有可套打模板。请先到「本厂模板」上传流程单识版。</p>
  20. </el-form-item>
  21. <el-form-item v-if="!lockBiz" label="打印对象">
  22. <el-radio-group v-model="form.biz_type" @change="onBizTypeChange">
  23. <el-radio label="workorder">工单</el-radio>
  24. <el-radio label="product">产品</el-radio>
  25. </el-radio-group>
  26. </el-form-item>
  27. <el-form-item v-else label="打印对象">
  28. <span>{{ form.biz_type === 'product' ? '产品' : '工单' }}</span>
  29. </el-form-item>
  30. <el-form-item :label="form.biz_type === 'product' ? '产品编号' : '工单编号'">
  31. <el-select
  32. v-model="form.no"
  33. filterable
  34. allow-create
  35. default-first-option
  36. clearable
  37. :disabled="lockBiz"
  38. :placeholder="form.biz_type === 'product' ? '选择已有产品,或直接输入编号' : '选择已有工单,或直接输入编号'"
  39. style="width:100%"
  40. @change="onNoChange"
  41. >
  42. <el-option
  43. v-for="item in bizOptions"
  44. :key="item.id"
  45. :label="optionLabel(item)"
  46. :value="optionValue(item)"
  47. />
  48. </el-select>
  49. <p class="field-hint">{{ isExisting ? '已匹配到本厂记录,将带出保存工艺' : '未匹配记录,可直接填写信息并临时添加工艺后打印' }}</p>
  50. </el-form-item>
  51. <el-form-item label="名称">
  52. <el-input v-model="form.name" :placeholder="form.biz_type === 'product' ? '产品名称' : '工单名称'" />
  53. </el-form-item>
  54. <el-form-item label="总数量">
  55. <el-input v-model="form.qty" placeholder="本次套打的计划总件数" />
  56. </el-form-item>
  57. <el-form-item label="每版数量">
  58. <el-input v-model="form.sheet_qty" placeholder="一版 = 一张流程单,仅本次套打使用" />
  59. </el-form-item>
  60. <el-form-item label="预计版数">
  61. <span class="plate-num">{{ plateTotal || '—' }}</span>
  62. <span v-if="sheetHint" class="field-hint inline">{{ sheetHint }}</span>
  63. </el-form-item>
  64. <el-form-item label="打印版数">
  65. <div class="range-row">
  66. <el-input-number v-model="form.sheet_from" :min="1" :max="plateMax" :disabled="!plateTotal" />
  67. <span class="range-sep">至</span>
  68. <el-input-number v-model="form.sheet_to" :min="form.sheet_from || 1" :max="plateMax" :disabled="!plateTotal" />
  69. </div>
  70. </el-form-item>
  71. <el-form-item label="整套份数">
  72. <el-input-number v-model="form.copies" :min="1" />
  73. </el-form-item>
  74. <el-form-item v-if="isExisting" label="保存工艺" class="process-item">
  75. <div v-if="savedProcesses.length" class="process-box">
  76. <div class="process-toolbar">
  77. <el-button text type="primary" @click="checkAllProcesses">全选</el-button>
  78. <el-button text @click="checkedProcessKeys = []">清空</el-button>
  79. <span class="field-hint inline">只打印勾选的工艺</span>
  80. </div>
  81. <el-checkbox-group v-model="checkedProcessKeys" class="process-checks">
  82. <el-checkbox
  83. v-for="(row, index) in savedProcesses"
  84. :key="index"
  85. :label="String(index)"
  86. >
  87. {{ processLabel(row) }}
  88. </el-checkbox>
  89. </el-checkbox-group>
  90. </div>
  91. <p v-else class="field-hint">该{{ form.biz_type === 'product' ? '产品' : '工单' }}还没有保存工艺,本次套打不带工序。</p>
  92. </el-form-item>
  93. <el-form-item v-else label="打印工艺" class="process-item">
  94. <div class="process-box">
  95. <p class="field-hint" style="margin:0 0 8px">直接输入的工单/产品只需填写工艺名称,仅用于本次打印。</p>
  96. <ProcessEditor v-model="draftProcesses" name-only />
  97. </div>
  98. </el-form-item>
  99. </el-form>
  100. <div v-if="preview" class="preview">
  101. <el-alert
  102. :title="'将打印第 ' + preview.sheet_from + '–' + preview.sheet_to + ' 版,共 ' + preview.total_sheets + ' 张(总版数 ' + (preview.plate_total || preview.total_sheets) + ')'"
  103. type="success"
  104. :closable="false"
  105. />
  106. <p>工序:{{ (preview.processes || []).map(item => item.name).join('、') || '无' }}</p>
  107. <el-table :data="preview.sheets" size="small" max-height="160">
  108. <el-table-column prop="no_text" label="页码" width="100" />
  109. <el-table-column prop="qty_text" label="本张数量" />
  110. </el-table>
  111. </div>
  112. </div>
  113. <div class="print-right">
  114. <div class="tpl-panel">
  115. <h4>{{ selectedTemplate ? selectedTemplate.name : '模板样式' }}</h4>
  116. <div class="cover" :style="coverBox(selectedTemplate)">
  117. <img v-if="selectedCover" class="cover-img" :src="selectedCover" alt="套打底图" />
  118. <div v-else class="cover-empty">{{ form.template_id ? '暂无底图' : '请先选择模板' }}</div>
  119. <div v-if="selectedCover" class="cells">
  120. <div
  121. v-for="cell in selectedCells"
  122. :key="cell.id || cell.key"
  123. class="cell"
  124. :style="cellStyle(cell)"
  125. :title="cell.label || cell.key || '空格'"
  126. />
  127. </div>
  128. </div>
  129. <p class="tpl-hint">右侧对照格子。预览会自动选 PDF 打印机以便按 A5 出纸;默认标签机最宽约 110mm,套不准也看不到字。打印时在预览里改选车间打印机。</p>
  130. </div>
  131. </div>
  132. </div>
  133. <template #footer>
  134. <InstallLodopButton type="warning" />
  135. <el-button @click="onClose">取消</el-button>
  136. <el-button @click="onPrepare" :loading="preparing">预览拆张</el-button>
  137. <el-button type="primary" @click="onConfirm" :loading="saving">确认套打</el-button>
  138. </template>
  139. </el-dialog>
  140. </template>
  141. <script setup>
  142. import { computed, reactive, ref, watch } from 'vue'
  143. import { ElMessage } from 'element-plus'
  144. import ProcessEditor from '@/components/ProcessEditor.vue'
  145. import InstallLodopButton from '@/components/InstallLodopButton.vue'
  146. import {
  147. printConfirm,
  148. printPrepare,
  149. productInfo,
  150. productList,
  151. templateList,
  152. workorderInfo,
  153. workorderList,
  154. } from '@/api/ps'
  155. import { handleLodopError, runLodop } from '@/utils/lodop'
  156. import { fileUrl } from '@/utils/file'
  157. import { sheetCount, sheetCountText } from '@/utils/sheet'
  158. const props = defineProps({
  159. modelValue: { type: Boolean, default: false },
  160. bizType: { type: String, default: '' },
  161. bizId: { type: [Number, String], default: '' },
  162. templateId: { type: [Number, String], default: '' },
  163. no: { type: String, default: '' },
  164. name: { type: String, default: '' },
  165. })
  166. const syncing = ref(false)
  167. const emit = defineEmits(['update:modelValue', 'done'])
  168. const templates = ref([])
  169. const workorders = ref([])
  170. const products = ref([])
  171. const preparing = ref(false)
  172. const saving = ref(false)
  173. const preview = ref(null)
  174. const savedProcesses = ref([])
  175. const checkedProcessKeys = ref([])
  176. const draftProcesses = ref([])
  177. const form = reactive({
  178. template_id: '',
  179. biz_type: 'workorder',
  180. biz_id: '',
  181. no: '',
  182. name: '',
  183. sheet_qty: '',
  184. qty: '',
  185. sheet_from: 1,
  186. sheet_to: 1,
  187. copies: 1,
  188. })
  189. const lockBiz = computed(() => !!props.bizId)
  190. const lockTemplate = computed(() => !!props.templateId)
  191. const isExisting = computed(() => Number(form.biz_id) > 0)
  192. const bizOptions = computed(() => (form.biz_type === 'product' ? products.value : workorders.value))
  193. const plateTotal = computed(() => sheetCount(form.qty, form.sheet_qty))
  194. const plateMax = computed(() => Math.max(1, plateTotal.value || 1))
  195. const sheetHint = computed(() => sheetCountText(form.qty, form.sheet_qty))
  196. const selectedTemplate = computed(() =>
  197. templates.value.find((item) => Number(item.id) === Number(form.template_id)) || null
  198. )
  199. const selectedCover = computed(() => {
  200. const row = selectedTemplate.value
  201. if (!row) {
  202. return ''
  203. }
  204. return fileUrl(row.cover_url || row.cover || (row.schema && (row.schema.background || row.schema.file_url)) || '')
  205. })
  206. const selectedCells = computed(() => cellsOf(selectedTemplate.value))
  207. function optionValue(item) {
  208. if (form.biz_type === 'workorder') {
  209. return item.no || ''
  210. }
  211. return item.code || item.name || ''
  212. }
  213. function pickName(row, fallback = '') {
  214. if (!row) {
  215. return String(fallback || '').trim()
  216. }
  217. return String(
  218. row.product_name || (row.product && row.product.name) || row.name || fallback || ''
  219. ).trim()
  220. }
  221. function optionLabel(item) {
  222. if (form.biz_type === 'workorder') {
  223. const name = pickName(item)
  224. return name ? (item.no + ' / ' + name) : (item.no || '')
  225. }
  226. return (item.code || '无编号') + ' / ' + (item.name || '')
  227. }
  228. function processLabel(row) {
  229. const seq = row.seq ? row.seq + '. ' : ''
  230. const code = row.code ? '(' + row.code + ')' : ''
  231. return seq + (row.name || '未命名工序') + code
  232. }
  233. function cellsOf(row) {
  234. const schema = row && row.schema && typeof row.schema === 'object' ? row.schema : {}
  235. const cells = Array.isArray(schema.cells) ? schema.cells : []
  236. if (cells.length) {
  237. return cells
  238. }
  239. const elements = Array.isArray(schema.elements) ? schema.elements : []
  240. return elements.filter((el) => el && (el.type === 'field' || el.role === 'fill' || el.key))
  241. }
  242. function coverBox(row) {
  243. const page = (row && row.schema && row.schema.page) || {}
  244. const w = Number(page.w) || 210
  245. const h = Number(page.h) || 297
  246. return { paddingTop: ((h / w) * 100) + '%' }
  247. }
  248. function cellStyle(cell) {
  249. return {
  250. left: (cell.x || 0) + '%',
  251. top: (cell.y || 0) + '%',
  252. width: Math.max(1.2, cell.w || 4) + '%',
  253. height: Math.max(1.2, cell.h || 3) + '%',
  254. }
  255. }
  256. function labelOf(item) {
  257. if (item.is_system == 1) {
  258. return '(官方)'
  259. }
  260. if (item.license_type === 'market_single') {
  261. return '(单次)'
  262. }
  263. if (item.license_type === 'market_buyout') {
  264. return '(买断)'
  265. }
  266. return ''
  267. }
  268. function applySavedProcesses(list) {
  269. savedProcesses.value = Array.isArray(list) ? list.slice() : []
  270. checkedProcessKeys.value = savedProcesses.value.map((_, index) => String(index))
  271. draftProcesses.value = []
  272. }
  273. function findRowByNo(no) {
  274. const key = String(no || '').trim()
  275. if (!key) {
  276. return null
  277. }
  278. if (form.biz_type === 'workorder') {
  279. return workorders.value.find((item) => String(item.no) === key) || null
  280. }
  281. return products.value.find((item) => String(item.code) === key || String(item.name) === key) || null
  282. }
  283. function syncPlateRange(prevTotal) {
  284. const n = plateTotal.value
  285. if (!(n > 0)) {
  286. form.sheet_from = 1
  287. form.sheet_to = 1
  288. return
  289. }
  290. if (form.sheet_from < 1 || form.sheet_from > n) {
  291. form.sheet_from = 1
  292. }
  293. const stretchEnd = !form.sheet_to || form.sheet_to > n || !prevTotal || form.sheet_to === prevTotal
  294. if (stretchEnd) {
  295. form.sheet_to = n
  296. }
  297. if (form.sheet_to < form.sheet_from) {
  298. form.sheet_to = n
  299. }
  300. }
  301. function checkAllProcesses() {
  302. checkedProcessKeys.value = savedProcesses.value.map((_, index) => String(index))
  303. }
  304. function onBizTypeChange() {
  305. form.biz_id = ''
  306. form.no = ''
  307. form.name = ''
  308. form.qty = ''
  309. form.sheet_qty = ''
  310. form.sheet_from = 1
  311. form.sheet_to = 1
  312. savedProcesses.value = []
  313. checkedProcessKeys.value = []
  314. draftProcesses.value = []
  315. preview.value = null
  316. }
  317. async function onNoChange(val) {
  318. if (syncing.value || lockBiz.value) {
  319. return
  320. }
  321. preview.value = null
  322. const key = String(val || '').trim()
  323. if (!key) {
  324. form.biz_id = ''
  325. savedProcesses.value = []
  326. checkedProcessKeys.value = []
  327. return
  328. }
  329. const row = findRowByNo(key)
  330. if (row) {
  331. await loadBizDetail(row.id)
  332. return
  333. }
  334. form.biz_id = ''
  335. savedProcesses.value = []
  336. checkedProcessKeys.value = []
  337. }
  338. async function loadBizDetail(id) {
  339. if (!id) {
  340. return
  341. }
  342. syncing.value = true
  343. try {
  344. if (form.biz_type === 'workorder') {
  345. const row = await workorderInfo(id)
  346. form.biz_id = row.id
  347. form.no = row.no || form.no || ''
  348. form.name = pickName(row, form.name)
  349. const procs = row.processes && row.processes.length
  350. ? row.processes
  351. : ((row.product && row.product.processes) || [])
  352. applySavedProcesses(procs)
  353. } else {
  354. const row = await productInfo(id)
  355. form.biz_id = row.id
  356. form.no = row.code || form.no || ''
  357. form.name = pickName(row, form.name)
  358. applySavedProcesses(row.processes || [])
  359. }
  360. syncPlateRange()
  361. } finally {
  362. syncing.value = false
  363. }
  364. }
  365. function selectedProcesses() {
  366. if (isExisting.value) {
  367. return savedProcesses.value.filter((_, index) => checkedProcessKeys.value.includes(String(index)))
  368. }
  369. return (draftProcesses.value || []).filter((row) => String(row.name || '').trim())
  370. }
  371. function payload() {
  372. return {
  373. template_id: form.template_id,
  374. biz_type: form.biz_type,
  375. biz_id: Number(form.biz_id) || 0,
  376. no: form.no,
  377. code: form.biz_type === 'product' ? form.no : '',
  378. name: form.name,
  379. sheet_qty: form.sheet_qty,
  380. qty: form.qty,
  381. sheet_from: form.sheet_from,
  382. sheet_to: form.sheet_to,
  383. copies: form.copies,
  384. processes: selectedProcesses(),
  385. }
  386. }
  387. function validate() {
  388. if (!form.template_id) {
  389. ElMessage.warning('请选择套打模板')
  390. return false
  391. }
  392. if (!String(form.no || '').trim() && !String(form.name || '').trim()) {
  393. ElMessage.warning('请填写工单或产品编号、名称')
  394. return false
  395. }
  396. if (!(Number(form.qty) > 0) || !(Number(form.sheet_qty) > 0)) {
  397. ElMessage.warning('请填写总数量和每版数量')
  398. return false
  399. }
  400. if (form.sheet_from < 1 || form.sheet_to < form.sheet_from || form.sheet_to > plateMax.value) {
  401. ElMessage.warning('请选择正确的打印起始、结束版数')
  402. return false
  403. }
  404. return true
  405. }
  406. async function onPrepare() {
  407. if (!validate()) {
  408. return
  409. }
  410. preparing.value = true
  411. try {
  412. preview.value = await printPrepare(payload())
  413. } finally {
  414. preparing.value = false
  415. }
  416. }
  417. async function onConfirm() {
  418. if (!validate()) {
  419. return
  420. }
  421. saving.value = true
  422. try {
  423. const data = await printConfirm(payload())
  424. ElMessage.success('已确认,流水号 ' + data.print_no + ',共 ' + data.total_sheets + ' 张')
  425. if (data.lodop) {
  426. try {
  427. await runLodop(data.lodop)
  428. } catch (e) {
  429. await handleLodopError(e)
  430. }
  431. }
  432. emit('done', data)
  433. onClose()
  434. } finally {
  435. saving.value = false
  436. }
  437. }
  438. function onClose() {
  439. emit('update:modelValue', false)
  440. }
  441. watch(
  442. () => props.modelValue,
  443. async (open) => {
  444. if (!open) {
  445. return
  446. }
  447. preview.value = null
  448. savedProcesses.value = []
  449. checkedProcessKeys.value = []
  450. draftProcesses.value = []
  451. syncing.value = true
  452. form.template_id = Number(props.templateId || 0) || ''
  453. form.biz_type = props.bizType || 'workorder'
  454. form.biz_id = Number(props.bizId || 0) || ''
  455. form.no = String(props.no || '').trim()
  456. form.name = String(props.name || '').trim()
  457. form.sheet_qty = ''
  458. form.qty = ''
  459. form.sheet_from = 1
  460. form.sheet_to = 1
  461. form.copies = 1
  462. try {
  463. const [t, w, p] = await Promise.all([
  464. templateList({ printable: 1, limit: 100 }),
  465. workorderList({ limit: 100 }),
  466. productList({ limit: 100 }),
  467. ])
  468. templates.value = t.rows || []
  469. workorders.value = w.rows || []
  470. products.value = p.rows || []
  471. if (form.biz_id) {
  472. const listed = (form.biz_type === 'product' ? products.value : workorders.value).find(
  473. (item) => Number(item.id) === Number(form.biz_id)
  474. )
  475. if (listed) {
  476. form.no = form.no || (form.biz_type === 'product' ? listed.code : listed.no) || ''
  477. form.name = form.name || pickName(listed)
  478. }
  479. await loadBizDetail(form.biz_id)
  480. }
  481. } finally {
  482. syncing.value = false
  483. }
  484. }
  485. )
  486. watch(plateTotal, (n, old) => {
  487. if (!props.modelValue) {
  488. return
  489. }
  490. syncPlateRange(old)
  491. })
  492. </script>
  493. <style scoped>
  494. .print-layout {
  495. display: flex;
  496. gap: 20px;
  497. align-items: stretch;
  498. min-height: 480px;
  499. }
  500. .print-left {
  501. flex: 1;
  502. min-width: 0;
  503. }
  504. .print-right {
  505. width: 380px;
  506. flex-shrink: 0;
  507. }
  508. .field-hint {
  509. margin: 6px 0 0;
  510. color: #7a8794;
  511. font-size: 12px;
  512. line-height: 1.4;
  513. }
  514. .field-hint.inline {
  515. margin: 0 0 0 8px;
  516. }
  517. .plate-num {
  518. font-size: 18px;
  519. font-weight: 600;
  520. color: #1d4f91;
  521. }
  522. .range-row {
  523. display: flex;
  524. align-items: center;
  525. gap: 8px;
  526. }
  527. .range-sep { color: #7a8794; }
  528. .process-item :deep(.el-form-item__content) { display: block; }
  529. .process-box {
  530. width: 100%;
  531. padding: 10px 12px;
  532. background: #f6f8fb;
  533. border-radius: 8px;
  534. }
  535. .process-toolbar {
  536. display: flex;
  537. align-items: center;
  538. gap: 4px;
  539. margin-bottom: 8px;
  540. }
  541. .process-checks {
  542. display: flex;
  543. flex-direction: column;
  544. gap: 4px;
  545. max-height: 180px;
  546. overflow: auto;
  547. }
  548. .preview { margin-top: 12px; }
  549. .preview p { color: #7a8794; margin: 8px 0; }
  550. .tpl-panel {
  551. position: sticky;
  552. top: 0;
  553. padding: 12px;
  554. background: #f4f6fb;
  555. border-radius: 10px;
  556. }
  557. .tpl-panel h4 {
  558. margin: 0 0 10px;
  559. font-size: 14px;
  560. font-weight: 600;
  561. }
  562. .cover {
  563. position: relative;
  564. border-radius: 8px;
  565. overflow: hidden;
  566. background: #fff;
  567. border: 1px solid #e5e9ef;
  568. }
  569. .cover-img,
  570. .cover-empty,
  571. .cells {
  572. position: absolute;
  573. inset: 0;
  574. width: 100%;
  575. height: 100%;
  576. }
  577. .cover-img { object-fit: fill; display: block; }
  578. .cover-empty {
  579. color: #8a97a6;
  580. display: flex;
  581. align-items: center;
  582. justify-content: center;
  583. font-size: 13px;
  584. }
  585. .cells { pointer-events: none; }
  586. .cell {
  587. position: absolute;
  588. box-sizing: border-box;
  589. border: 1px solid rgba(64, 158, 255, 0.95);
  590. background: rgba(64, 158, 255, 0.18);
  591. }
  592. .tpl-hint {
  593. margin: 10px 0 0;
  594. color: #7a8794;
  595. font-size: 12px;
  596. line-height: 1.5;
  597. }
  598. .empty-hint { margin: 6px 0 0; color: #c45656; font-size: 12px; }
  599. @media (max-width: 960px) {
  600. .print-layout { flex-direction: column; }
  601. .print-right { width: 100%; }
  602. }
  603. </style>
  604. <style>
  605. .print-overprint-dialog .el-dialog__body {
  606. max-height: 74vh;
  607. overflow: auto;
  608. padding-top: 12px;
  609. }
  610. </style>